wan의 개발일기

[Flutter] 4. 프로필(닉네임/사진) 만들고 채팅 이미지까지 전송하기 본문

Flutter

[Flutter] 4. 프로필(닉네임/사진) 만들고 채팅 이미지까지 전송하기

wan_ 2026. 6. 24. 12:44

소스코드 → github.com/Jywan/Flutter_Learning

들어가며

이번 글은 시간을 살짝 되돌린다. 실제 커밋 순서로는 프로필 기능 → 채팅 UI 개선 → 이미지 전송이 3편(그룹 채팅)보다 먼저 만들어졌다. 시리즈 구성상 그룹 채팅을 3편에 먼저 배치했을 뿐, 코드 상으로는 이번 글에서 다루는 기능들이 더 이전 단계다. 3편에서 "이건 4편 소재"라고 미뤄뒀던 _otherUserProfileUrl, _sendImage()가 바로 여기서 나온다.

이메일/UID만 들고 있던 FirebaseAuth 유저에, "보여줄 수 있는" 정보(닉네임, 프로필 사진)를 어떻게 붙였는지, 그리고 텍스트만 주고받던 채팅에 이미지를 어떻게 끼워 넣었는지를 다룬다.


프로필 화면 만들기

새 화면 ProfileScreen을 만들고, 홈 화면 AppBar에 프로필 아이콘을 추가해서 진입할 수 있게 했다. 화면을 열면 Firestore에 저장된 닉네임/프로필 사진을 불러와 보여준다.

Future<void> _loadProfile() async {
  final uid = FirebaseAuth.instance.currentUser!.uid;
  final doc = await FirebaseFirestore.instance.collection('users').doc(uid).get();

  if (doc.exists) {
    final data = doc.data()!;
    _nicknameController.text = data['nickname'] ?? '';
    setState(() {
      _profileImageUrl = data['profileImageUrl'];
    });
  }
}

닉네임 저장은 단순히 Firestore 문서를 update()하는 것으로 끝난다. 인증 정보(FirebaseAuth)와 분리해서 Firestore에 둔 덕분에, "닉네임 변경"이 곧 일반적인 DB 업데이트 한 줄이 됐다 — 2편에서 미리 그렇게 구조를 짜둔 게 여기서 빛을 본 셈이다.

Future<void> _saveNickname() async {
  final nickname = _nicknameController.text.trim();
  if (nickname.isEmpty) return;

  final uid = FirebaseAuth.instance.currentUser!.uid;
  await FirebaseFirestore.instance.collection('users').doc(uid).update({
    'nickname': nickname,
  });
}

프로필 사진 업로드

이미지 업로드는 두 단계다. ① image_picker로 갤러리에서 사진을 고르고, ② firebase_storage에 올린 뒤 받은 URL을 Firestore 문서에 저장한다.

Future<void> _pickAndUploadImage() async {
  final picker = ImagePicker();
  final picked = await picker.pickImage(source: ImageSource.gallery, maxWidth: 512);
  if (picked == null) return;

  final uid = FirebaseAuth.instance.currentUser!.uid;
  final ref = FirebaseStorage.instance.ref().child('profiles/$uid.jpg');

  // 웹과 네이티브(Android/iOS)는 파일 다루는 방식이 다르다
  if (kIsWeb) {
    final bytes = await picked.readAsBytes();
    await ref.putData(bytes, SettableMetadata(contentType: 'image/jpg'));
  } else {
    await ref.putFile(File(picked.path));
  }

  final url = await ref.getDownloadURL();
  await FirebaseFirestore.instance.collection('users').doc(uid).update({
    'profileImageUrl': url,
  });
}

웹 브라우저는 파일시스템 경로(File)에 접근할 수 없어서 readAsBytes()로 바이트째 읽어 putData()로 올려야 하고, 모바일은 picked.path로 실제 파일을 가리켜 putFile()을 쓸 수 있다. kIsWeb 분기를 안 해주면 웹에서 File 생성 자체가 막혀서 컴파일이 안 된다. Node.js에서 브라우저용 코드와 서버용 코드를 분기하는 것과 비슷한 느낌이다.

저장 경로를 profiles/{uid}.jpg로 고정해뒀기 때문에, 다시 업로드하면 같은 경로의 파일이 덮어써진다 — 별도로 이전 이미지를 지우는 코드가 필요 없다.


채팅에 닉네임/프로필 사진 반영하기

프로필을 만들었으니, 이제 채팅 화면에서 이메일 대신 닉네임과 프로필 사진을 보여줘야 한다. ChatScreen이 열릴 때 상대방의 이메일로 Firestore를 조회해서 닉네임/사진 URL을 가져온다.

Future<void> _loadOtherUserInfo() async {
  final query = await FirebaseFirestore.instance
      .collection('users')
      .where('email', isEqualTo: widget.otherUserEmail)
      .limit(1)
      .get();

  if (query.docs.isNotEmpty) {
    final data = query.docs.first.data();
    setState(() {
      _otherUserNickname = data['nickname'] ?? widget.otherUserEmail;
      _otherUserProfileUrl = data['profileImageUrl'];
    });
  }
}

AppBar 제목을 Text(otherUserEmail) 한 줄에서, 프로필 사진 CircleAvatar + 닉네임으로 바꿨다. 메시지 버블도 상대방 메시지마다 작은 프로필 아이콘을 붙이고, 시간(HH:mm)을 옆에 표시하도록 디자인을 다시 짰다. 채팅방 목록(home_screen.dart)도 똑같은 방식으로 FutureBuilder를 추가해서 닉네임/프로필/시간을 보여주게 했다.

메시지를 보내면 _scrollController.animateTo(maxScrollExtent)로 맨 아래로 스크롤하는 것도 이때 추가했다. 그 전까지는 메시지를 보내도 화면이 그대로 있어서, 새 메시지를 보려면 직접 스크롤을 내려야 했다.


채팅 이미지 전송

프로필 사진 업로드와 거의 같은 패턴이다. 다만 저장 경로가 chats/{chatId}/{timestamp}.jpg로, 메시지마다 새 파일이 쌓이도록 타임스탬프를 파일명에 넣었다.

Future<void> _sendImage() async {
  final picker = ImagePicker();
  final picked = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024);
  if (picked == null) return;

  setState(() => _isUploading = true);

  try {
    final currentUser = FirebaseAuth.instance.currentUser!;
    final timestamp = DateTime.now().microsecondsSinceEpoch;
    final ref = FirebaseStorage.instance
      .ref()
      .child('chats/${widget.chatId}/$timestamp.jpg');

    if (kIsWeb) {
      final bytes = await picked.readAsBytes();
      await ref.putData(bytes, SettableMetadata(contentType: 'image/jpeg'));
    } else {
      await ref.putFile(File(picked.path));
    }

    final imageUrl = await ref.getDownloadURL();

    // 메시지 타입을 'image'로 구분해서 저장
    await FirebaseFirestore.instance
      .collection('chats').doc(widget.chatId).collection('messages')
      .add({
        'text': '',
        'imageUrl': imageUrl,
        'senderId': currentUser.uid,
        'senderEmail': currentUser.email,
        'type': 'image',
        'timestamp': FieldValue.serverTimestamp(),
      });

    await FirebaseFirestore.instance.collection('chats').doc(widget.chatId).update({
      'lastMessage': '사진',
      'lastMessageTime': FieldValue.serverTimestamp(),
    });
  } finally {
    setState(() => _isUploading = false);
  }
}

메시지 문서에 type: 'image' 필드를 추가해서, 채팅 화면에서 버블을 그릴 때 텍스트인지 이미지인지 분기한다.

child: type == 'image'
  ? ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Image.network(
        msg['imageUrl'] ?? '',
        width: 200,
        fit: BoxFit.cover,
        loadingBuilder: (context, child, loadingProgress) {
          if (loadingProgress == null) return child;
          // 로딩 중엔 같은 크기의 스피너로 자리 차지
          return const SizedBox(
            width: 200, height: 150,
            child: Center(child: CircularProgressIndicator()),
          );
        },
      ),
    )
  : Text(msg['text'] ?? ''),

업로드하는 동안엔 _isUploading 플래그로 입력창 위에 작은 로딩 인디케이터를 띄우고, 이미지 버튼도 비활성화해서 중복 전송을 막았다.

Firebase Storage CORS 설정

Flutter Web에서 테스트할 때 이미지가 안 뜨는 문제를 겪었는데, 원인은 Storage의 CORS 정책이었다. 모바일 앱은 HTTP 클라이언트가 직접 받아오니 문제가 없는데, 웹은 브라우저가 다른 origin(firebasestorage.googleapis.com)으로의 요청을 CORS 헤더로 검증한다. gsutil로 아래 설정을 Storage 버킷에 적용해서 해결했다.

[{"origin": ["*"], "method": ["GET"], "maxAgeSeconds": 3600}]
gsutil cors set cors.json gs://<your-project>.appspot.com

실행 화면

아래는 프로필 화면이다. 다크 모드 토글은 7편에서 추가된 기능이라, 이번 편 코드에는 아직 없다.

프로필 화면
프로필 화면 (※ 다크 모드 토글은 7편에서 추가)

정리

기능핵심 API / 구조
닉네임 저장Firestore users/{uid} 문서 update()
프로필 사진 업로드FirebaseStorage + profiles/{uid}.jpg 고정 경로
채팅에 닉네임/사진 반영이메일로 users 컬렉션 조회 (where + limit(1))
이미지 메시지 구분메시지 문서의 type: 'image' 필드
웹/네이티브 분기 업로드kIsWeb + putData() / putFile()
웹 이미지 로딩 문제Storage 버킷 CORS 설정 (gsutil cors set)

다음 글에서는 시간 순서를 다시 따라가서, 그룹 채팅(3편) 다음에 추가된 읽음 표시타이핑 인디케이터를 다룬다.