Flutter 개발

Flutter 메신저 앱에 닉네임과 프로필 이미지 추가하기 (Firebase + SharedPreferences 활용)

withmilk 2025. 5. 4. 14:09

이전 글까지 Flutter로 만든 메신저 앱, 잘 작동하긴 하지만 뭔가 부족하지 않으셨나요?
오늘은 메신저 앱에 닉네임 설정프로필 이미지 표시 기능을 추가해 보면서,
좀 더 사용자다운 느낌, 그리고 예쁜 인터페이스를 만드는 법을 알아보겠습니다!

이 글은 초보자도 쉽게 따라 할 수 있도록 하나하나 순서대로,
그리고 코드 예제까지 포함해서 설명할게요. 😉


📌 오늘 목표

요약

  • 사용자가 앱 처음 실행할 때 닉네임과 프로필 이미지를 설정할 수 있어요.
  • 메시지를 보낼 때 닉네임과 이미지도 함께 저장돼요.
  • 메시지 목록에서 닉네임과 프로필 이미지가 함께 보여요.

🧰 사용 기술 요약

  • Firebase Realtime Database : 메시지 저장용
  • SharedPreferences : 닉네임, 이미지 경로를 로컬에 저장함
  • ImagePicker : 갤러리에서 사진 선택

닉네임 & 프로필 이미지 설정 화면 만들기

보통 카카오톡이나 다른 메신저를 사용해보셨다면 알겠지만,

메시지를 보내는 화면과 프로필, 닉네임을 설정할 수 있는 화면이 달라요.

닉네임과 프로필을 설정할 수 있는 화면을 하나 따로 만들어볼게요.

 

UserSetupScreen이라는 화면을 하나 만들어봐요.

먼저 image_picker와 shared_preferences 패키지를 사용하기 위해서 pubspec.yaml에 의존성을 추가해주세요.

pubspec.yaml 파일에 image_picker 추가한 모습

 

user_setup_screen.dart

새로운 유저 설정 화면을 구현해볼까요? 아래와 같이 코드를 작성해주세요.

 

import 'package:easy_messenger/chat_screen.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:io';

class UserSetupScreen extends StatefulWidget {
  @override
  _UserSetupScreenState createState() => _UserSetupScreenState();
}

class _UserSetupScreenState extends State<UserSetupScreen> {
  final TextEditingController _nameController = TextEditingController();
  File? _selectedImage;

  void _pickImage() async {
    final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
    if (picked != null) {
      setState(() {
        _selectedImage = File(picked.path);
      });
    }
  }

  void _saveUserInfo() async {
    if (_nameController.text.isEmpty || _selectedImage == null) return;

    final prefs = await SharedPreferences.getInstance();
    prefs.setString('nickname', _nameController.text);
    prefs.setString('profileImagePath', _selectedImage!.path);

    if (context.mounted) {
      Navigator.pushAndRemoveUntil(
        context,
        MaterialPageRoute(builder: (context) => ChatScreen()),
        (route) => false,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('닉네임 설정')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            GestureDetector(
              onTap: _pickImage,
              child: CircleAvatar(
                radius: 50,
                backgroundImage:
                    _selectedImage != null ? FileImage(_selectedImage!) : null,
                child:
                    _selectedImage == null
                        ? Icon(Icons.camera_alt, size: 40)
                        : null,
              ),
            ),
            SizedBox(height: 16),
            TextField(
              controller: _nameController,
              decoration: InputDecoration(labelText: '닉네임 입력'),
            ),
            SizedBox(height: 16),
            ElevatedButton(onPressed: _saveUserInfo, child: Text('시작하기')),
          ],
        ),
      ),
    );
  }
}

 

코드 구현 설명

  1. _pickImage 함수에서는 image_picker 라이브러리를 사용해서 갤러리의 이미지를 불러와서 저장하는 기능을 구현했어요.
    선택된 이미지는 CircleAvatar 위젯을 통해서 프로필 사진처럼 보여줄거에요.
  2. _saveUserInfo 함수에서는 닉네임을 입력하고, 사진을 선택했을 때에 그 값들을 shared preferences에 저장하는 기능을 구현했어요. 닉네임과 프로필 이미지의 파일 위치가 저장이 완료되면 Navigator를 통해서 ChatScreen으로 자동으로 이동할거에요.

 

그리고 이제 앱을 처음 열었을 때 유저가 먼저 프로필 설정을 할 수 있도록 설정 화면이 보이면 좋겠죠?

main.dart 파일도 아래와 같이 바꿔볼게요.

main.dart

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'chat_screen.dart';
import 'user_setup_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 사용자 설정 여부 확인
  final prefs = await SharedPreferences.getInstance();
  final hasNickname = prefs.containsKey('nickname');
  final hasProfileImage = prefs.containsKey('profileImagePath');

  runApp(MyApp(showSetupScreen: !(hasNickname && hasProfileImage)));
}

class MyApp extends StatelessWidget {
  final bool showSetupScreen;

  const MyApp({required this.showSetupScreen});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter 메신저',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: showSetupScreen ? UserSetupScreen() : ChatScreen(),
    );
  }
}

코드 구현 설명

main함수에서 MyApp을 생성하기 전에, shared preferences 를 먼저 체크하는 거에요. 닉네임과 프로필이 설정되어 있지 않다면 UserSetupScreen 화면을 먼저 보여주고, 이미 닉네임과 프로필 이미지 설정을 완료했으면 바로 ChatScreen이 켜지는 거에요.

 

user_setup_screen.dart를 구현하고, main.dart 함수를 변경하고 앱을 실행해보면 다음 이미지처럼 앱을 켰을 때 바로 설정 화면으로 이동하는 것을 볼 수 있어요. 하지만 닉네임과 프로필 이미지를 설정하고 접속해도 아직 채팅 화면에는 아무런 변화가 없어요. 이제 채팅 화면에서 닉네임과 프로필 이미지가 보이도록 수정을 해볼까요?

user_setup_screen.dart를 실행했을 때의 모습.

 


채팅 화면에서 사용자 정보 불러오기

chat_screen.dart

채팅 화면 코드를 다음과 같이 변경해볼게요.

import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart'; // 시간 포맷을 위해 필요해요.
import 'dart:io';

class ChatScreen extends StatefulWidget {
  @override
  _ChatScreenState createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final TextEditingController _controller = TextEditingController();
  final DatabaseReference _messagesRef = FirebaseDatabase.instance.ref(
    'messages',
  );

  String? myNickname;
  String? myProfilePath;

  void _loadUserInfo() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      myNickname = prefs.getString('nickname') ?? '익명';
      myProfilePath = prefs.getString('profileImagePath');
    });
  }

  @override
  void initState() {
    super.initState();
    _loadUserInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Flutter 메신저')),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder(
              stream: _messagesRef.orderByChild('timestamp').onValue,
              builder: (context, snapshot) {
                if (snapshot.hasData && snapshot.data!.snapshot.value != null) {
                  Map data = snapshot.data!.snapshot.value as Map;
                  List items = data.entries.map((e) => e.value).toList();
                  items.sort(
                    (a, b) => a['timestamp'].compareTo(b['timestamp']),
                  ); // 시간순
                  return ListView.builder(
                    itemCount: items.length,
                    itemBuilder: (context, index) {
                      print(
                        '[메세지 $index] ${DateFormat('HH:mm:ss').format(DateTime.fromMillisecondsSinceEpoch(items[index]['timestamp']))}',
                      );
                      bool isMe = items[index]['sender'] == myNickname;
                      return _buildMessageBubble(
                        message: items[index],
                        isMe: isMe,
                      );
                    },
                  );
                } else {
                  return Center(child: Text('아직 메시지가 없어요.'));
                }
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: InputDecoration(labelText: '메시지 입력'),
                  ),
                ),
                IconButton(icon: Icon(Icons.send), onPressed: _sendMessage),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      _messagesRef.push().set({
        'text': _controller.text,
        'sender': myNickname,
        'profile': myProfilePath,
        'timestamp': DateTime.now().millisecondsSinceEpoch,
      });
      _controller.clear();
    }
  }

  Widget _buildMessageBubble({required Map message, required bool isMe}) {
    final profilePath = message['profile'];

    return Row(
      mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
      children: [
        if (isMe && profilePath != null)
          CircleAvatar(backgroundImage: FileImage(File(profilePath))),
        Container(
          margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          padding: EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: isMe ? Colors.blueAccent : Colors.grey[300],
            borderRadius: BorderRadius.circular(16),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                message['sender'] ?? '익명',
                style: TextStyle(fontSize: 12, color: Colors.black54),
              ),
              Text(
                message['text'] ?? '',
                style: TextStyle(color: isMe ? Colors.white : Colors.black),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

코드 변경점 설명

  1. myNickname을 이전에는 "나" 라는 텍스트를 설정해두었지만, 이제는 _loadUserInfo 함수를 통해서 shared preferences에 저장해둔 내 닉네임 값을 읽게 되었어요. 이제는 저장된 닉네임을 사용해서 채팅을 할거에요.
  2. _sendMessage 함수에서는 프로필 이미지의 저장 위치까지 한 번에 전송하도록 변경했어요.
  3. _buildMessageBundle 함수의 레이아웃이 변경되었어요.
    1. 이전 버전에서는 메세지가 가로로 너무 길게 보였는데, 메세지의 크기에 따라서 버블의 크기가 조절되도록 했어요.
    2. 메시지 버블에 내 닉네임과 프로필 사진이 보여요.

 

chat_screen.dart 함수까지 변경하고 앱을 실행해보면, 아래 사진처럼 새로운 메세지를 보냈을 때 설정 화면에서 설정한 내 닉네임과 프로필 이미지가 잘 보이는 것을 확인할 수 있어요.

설정한 닉네임과 프로필 이미지가 보이는 새로운 메세지 버블의 모습

 


❗ 로컬 이미지 경로의 한계

이번 예제에서는 사용자가 선택한 프로필 이미지를 ImagePicker()로 가져온 뒤,
그 경로를 SharedPreferences에 저장하고, 메시지를 보낼 때도 해당 경로를 함께 저장했습니다.
이 방식은 간단하고 빠르게 구현할 수 있다는 장점이 있지만,
실제 서비스 환경에서는 몇 가지 중요한 문제가 발생합니다.

 

Flutter를 한 번 종료했다가 flutter run 명령어로 다시 앱을 실행해보면 바로 문제를 확인할 수 있을거에요.

껐다가 키기만 했는데도 분명히 보이던 내 프로필 이미지가 안보이게 돼요.

재시작하게 되면 프로필 이미지가 보이지 않게된 모습

1. 📁 임시 경로는 사라질 수 있어요

ImagePicker()로 불러온 이미지는 보통 기기의 임시 저장소(cache 디렉토리) 에 저장됩니다.
이 디렉토리는 앱을 껐다 켜거나, 일정 시간이 지나면 시스템에 의해 자동으로 비워질 수 있어요. 🥲

그 결과, 사용자가 앱을 재실행했을 때 이전에 설정한 프로필 이미지가
더 이상 존재하지 않아서 표시되지 않는 문제가 생깁니다.
즉, 내 앱 안에서도 이미지가 깨지는 상황이 발생할 수 있는 거죠.

 

2. 🔒 내 이미지, 내 기기에서만 유효해요

또 하나의 중요한 문제는 저장된 이미지 경로가 내 스마트폰에서만 유효하다는 점입니다.

예를 들어, 내가 메시지를 보낼 때 내 기기 내부의 이미지 경로를 메시지와 함께 저장했다면,
그 메시지를 받은 상대방은 해당 경로에 접근할 수 없습니다. 🥲
상대방의 기기에는 그 이미지 파일이 존재하지 않기 때문에
상대방은 내 프로필 이미지를 전혀 볼 수 없게 됩니다.

이처럼 로컬 경로 방식은 이미지 공유가 불가능한 구조입니다. 

 

지금 방식은 혼자 테스트하거나 단기적으로 기능을 구성할 때는
빠르고 편하게 사용할 수 있지만,
멀티 유저 기반의 서비스로 발전시키기에는 구조적으로 부족합니다.


마무리하며

 

이 문제들을 해결하기 위해 다음 글에서는
Firebase Storage를 활용해 프로필 이미지를 안정적으로 저장하고 공유하는 방법을 다룰 예정입니다. 😆

Firebase Storage에 이미지를 업로드하고, 해당 이미지의 다운로드 URL을 얻어 메시지에 포함시키면, 모든 사용자가 어떤 기기에서든 동일한 이미지에 접근할 수 있게 됩니다.
이 방식은 실제 메신저 서비스에서 사용하는 표준적인 방법이에요.

 

다음 글에서는 다음과 같은 내용을 다룰 예정이에요:

  • firebase_storage 패키지로 이미지 업로드하기
  • 업로드된 이미지의 다운로드 URL 받아오기
  • URL을 Firebase Realtime Database에 함께 저장하기
  • NetworkImage로 프로필 이미지 안정적으로 표시하기

이제부터는 진짜 서비스 수준의 사용자 경험을 만들 수 있어요.
우리 메신저, 다음 단계로 함께 나아가 봅시다! 💬🚀

 

👉 “Firebase Storage로 프로필 이미지 저장하고 공유하기” 편에서 계속됩니다!