iOS) Firebase 를 이용한 회원가입과 프로필 사진 등록 Firebase Auth, Firebase Database, Firebase Storage

2021. 7. 24. 13:45iOS

회원가입 기능을 구현하려면 먼저 회원객체가 필요하다.

struct DuetUser {
    let firstName: String
    let lastName: String
    let emailAddress: String
    
    // To solve issue that not allow some special symbols("[", "@", "," ...), exchange symbols in email like ".", "@" to "-"
    var safeEmail: String {
        var safeEmail = emailAddress.replacingOccurrences(of: ".", with: "-")
        safeEmail = safeEmail.replacingOccurrences(of: "@", with: "-")
        return safeEmail
    }
    
    var profilePictureFileName : String {
        return "\(safeEmail)_profile_picture.png"
    }
}

회원은 성(firstName) 과 이름(lastName) 이메일주소(emailAddress) 프로필사진(profilePictureFileName) 이 필요하다.

여기서 safeEmail 인스턴스 속성은 파이어베이스 데이터베이스가 "@" 이나 "." 등의 특수기호를 허용하지 않기 때문에,  특수기호를 "-" 로 치환하여 사용하고 있다.

 

Firebase Database 공통모듈

회원가입을 담당할 공통 모듈을 DatabaseManager 클래스로 미리 만들어 놓는다. 여기서는 회원가입시 이메일의 중복체크, 그리고 사용가능한 이메일을 데이터베이스에 저장하는 작업이 포함된다.

 

1. 이메일 중복 체크

먼저 이메일 중복체크 로직을 userExists라는 이름의 함수로 지정한다.

 public func userExists(with email: String,
                       completion: @escaping ((Bool) -> Void))  {
    
    var safeEmail = email.replacingOccurrences(of: ".", with: "-")
    safeEmail = safeEmail.replacingOccurrences(of: "@", with: "-")
    
    // To check email is exist, find safeEmail in firebase database, and then check nil for snapshot.
    database.child(safeEmail).observeSingleEvent(of: .value) { snapshot in
        // if snapshot is nil, then completion closure argument will be allocated "false"
        guard snapshot.value as? String != nil else {
            completion(false)
            return
        }
        
        // completion closure argument will be allocated "true"
        completion(true)
    }
 }

 첫번째 인자에는 등록할 이메일을, 두번째 인자에는 userExists 함수 흐름이 모두 끝난뒤 실행될 completion 핸들러(이때 completion 인자에 Bool 타입)를 정한다. 

 

회원 객체에서처럼 이메일을 replacingOccurrnce(of:with:) 로 바꿔서 Firebase Database에 싣을수 있도록 값을 치환한다. 이후 Firebase database에 접근하여 safeEmail의 값을 가진 child가 있는지 확인하는데 단 한번만 확인하는 작업이므로 observeSingleEvent(of:)를 호출한다. 현재 데이터베이스에 이미 이메일이 존재한다면 결과로써 snapshot에는 해당 값에 대한 정보가 들어올 것이므로 snapshot에 대한 nil 체크를 해주면 후속 completion 핸들러에서 결과에 맞는 처리를 하면된다. 

 

데이터베이스에 이미 이메일이 존재한다면 snapshot은 not nil 이기에 completion 핸들러의 인자에는 true가 전달되고, 이메일이 존재하지 않아 회원가입이 가능하다면 snapshot 은 nil 이 할당되므로 completion 핸들러에는 false 가 전달된다.

 

2. 유저 정보를 데이터베이스에 등록

/// Insert new user to database
public func insertUser(with user: DuetUser, completion: @escaping (Bool) -> Void) {
    database.child(user.safeEmail).setValue([
        "first_name": user.firstName,
        "last_name": user.lastName
    ]) { error, reference in
        guard error == nil else {
            print("failed to write to database")
            completion(false)
            return
        }
        completion(true)
    }
}

Firebase에서 어떤 객체를 찾을 때, 그 객체의 String값으로 찾으려 한다면 child(<Object String Value>) 으로 찾으면 된다. 이후 해당 객체의 아래에 값을 Append 하고 싶으면 setValue() 에 값을 담아 전달하면 된다. setValue 역시도 completion handler를 지원하여 결과처리를 용이하게 만들어놨는데, 서버통신 결과에 따라 NSError 혹은 FIRDatabaseReference 객체가 리턴된다. error nil checking 을 통해 completion handler에 적절한 Bool 타입을 싣어보면 된다. 우리는 외부에서 이 함수를 호출하여 회원가입 로직의 흐름을 컨트롤 할 것이므로 Firebase 에서 제공하는 completion result 타입들을 확인하는 것이 매우 중요하다.

 

DatabaseManager 의 전체코드는 아래와 같다. 

import Foundation
import FirebaseDatabase

final class DatabaseManager {
    static let shared = DatabaseManager()
    private let database = Database.database().reference()

}

// MARK: - Account Management
extension DatabaseManager {
    
    public func userExists(with email: String,
                           completion: @escaping ((Bool) -> Void))  {
        
        var safeEmail = email.replacingOccurrences(of: ".", with: "-")
        safeEmail = safeEmail.replacingOccurrences(of: "@", with: "-")
        
        // To check email is exist, find safeEmail in firebase database, and then check nil for snapshot.
        database.child(safeEmail).observeSingleEvent(of: .value) { snapshot in
            // if snapshot is nil, then completion closure argument will be allocated "false"
            guard snapshot.value as? String != nil else {
                completion(false)
                return
            }
            
            // completion closure argument will be allocated "true"
            completion(true)
        }
    }
    
    /// Insert new user to database
    public func insertUser(with user: DuetUser, completion: @escaping (Bool) -> Void) {
        database.child(user.safeEmail).setValue([
            "first_name": user.firstName,
            "last_name": user.lastName
        ]) { error, reference in
            guard error == nil else {
                print("failed to write to database")
                completion(false)
                return
            }
            completion(true)
        }
    }
}

struct DuetUser {
    let firstName: String
    let lastName: String
    let emailAddress: String
    
    // To solve issue that not allow some special symbols("[", "@", "," ...), exchange symbols in email like ".", "@" to "-"
    var safeEmail: String {
        var safeEmail = emailAddress.replacingOccurrences(of: ".", with: "-")
        safeEmail = safeEmail.replacingOccurrences(of: "@", with: "-")
        return safeEmail
    }
    
    var profilePictureFileName : String {
        return "\(safeEmail)_profile_picture.png"
    }
}

 

 

Firebase Storage 공통모듈

 

다음은 프로필 사진을 저장할 Firebase Storage 관련 모듈을 만들었다. 이 모듈에서는 인자로 들어오는 바이너리 파일 데이터와 파일 이름으로 Firebase Storage와 통신하여 저장하고 함수종료이후 completion 핸들러가 이행되도록 한다.

public func uploadProfilePicture(with data: Data, fileName: String, completion: @escaping UploadPictureCompletion) {
    storage.child("images/\(fileName)").putData(data, metadata: nil, completion: { [weak self] metadata, error in
        guard let strongSelf = self else {
            return
        }
        guard error == nil else {
            // failed
            print("failed to upload data to firebase for picture")
            completion(.failure(StorageErrors.failedToUpload))
            return
        }
        
        strongSelf.storage.child("images/\(fileName)").downloadURL { url, error in
            guard let url = url else {
                print("Failed to get download url")
                completion(.failure(StorageErrors.failedToGetDownloadUrl))
                return
            }
            let urlString = url.absoluteString
            print("download url returned: \(urlString)")
            completion(.success(urlString))
        }
    })
}

Firebase Storage의 putData 메소드를 사용하여 "images/" 디렉토리 아래에 "fileName"의 이름으로 data를 저장하고 completion 핸들러를 실행한다. 이때 연결과정에서 에러가 있으면 closure argument 중 error에 값이 전달된다. 따라서 error가 nil이 아니라면 uploadProfilePicture 의 completion (storage putData의 completion과 다름) 에 Failure Result를 담아 실행되도록 한다. 

 

이 모듈은 단지 Firebase Storage에 데이터를 저장하는 것뿐만이 아니라 해당 이미지의 절대(absolute)주소값을 받아오는 기능도 함께 구현한다. Firebase Storage는 다행히도 downloadURL이라는 메소드를 지원하여 쉽게 등록된 이미지 자원의 주소값을 가져올 수 있다.

 

코드에서는 child("images/\(fileName)") 메소드를 통해 "images/" 디렉토리 아래의 "fileName"을 찾아 URL을 다운로드한다. 이때 성공적으로 url이 받아졌다면 closure 안의 url은 NSURL 객체가 받아지게 된다. 따라서 우리가 원하는 절대 주소값을 가져오려면 NSURL의 프로퍼티인 absoluteString을 사용해 반환받아야 한다. 정상적으로 받아왔다면 completion 에 절대주소 String를 인자로 Success Result를 담아 실행되도록 한다. 

 

Firebase Storage와 관련된 모듈은 StorageManager 파일에 담아 정리한다.

전체코드는 아래와 같다.

import Foundation
import FirebaseStorage

final class StorageManager {
    
    static let shared = StorageManager()
    
    private let storage = Storage.storage().reference()
   
    public typealias UploadPictureCompletion = (Result<String, Error>) -> Void
    
    /// Uploads picture to firebase storage and returns completion with url string to download
    public func uploadProfilePicture(with data: Data, fileName: String, completion: @escaping UploadPictureCompletion) {
        storage.child("images/\(fileName)").putData(data, metadata: nil, completion: { [weak self] metadata, error in
            guard let strongSelf = self else {
                return
            }
            guard error == nil else {
                // failed
                print("failed to upload data to firebase for picture")
                completion(.failure(StorageErrors.failedToUpload))
                return
            }
            
            strongSelf.storage.child("images/\(fileName)").downloadURL { url, error in
                guard let url = url else {
                    print("Failed to get download url")
                    completion(.failure(StorageErrors.failedToGetDownloadUrl))
                    return
                }
                let urlString = url.absoluteString
                print("download url returned: \(urlString)")
                completion(.success(urlString))
            }
        })
    }
    
    public enum StorageErrors: Error {
        case failedToUpload
        case failedToGetDownloadUrl
    }
    
}

 

이제 만들어둔 DatabaseManager, StorageManager를 이용해서 회원가입시 유저 정보를 Database에 저장하고 유저 프로필 사진을 Storage에 저장한다. 이때 UserDefaults 에 프로필 사진의 절대경로 문자열을 영구적으로 저장한다.

 

아래 코드는 회원가입(Register) 버튼을 눌렀을 때 실행되는 전체 로직이다. 

아래에서 하나하나 살펴보도록 하자.

@objc private func didTapRegisterButton() {
     // check user email has registered already, and then execute completion handler.
     DatabaseManager.shared.userExists(with: email, completion: { [weak self] exists in
         guard let strongSelf = self else {
             return
         }
            
         // when exists is False.
         guard !exists else {
             strongSelf.alertUserLoginError(message: "Looks like a user account for that email address already exists.")
             return
         }
         
         // create user information first, then result or error will be returned.
         FirebaseAuth.Auth.auth().createUser(withEmail: email, password: password) { (authResult, error) in
             guard authResult != nil, error == nil else {
                 print("Error creating user")
                 return
             }
             
             // if result is good to go, user information should be inserted in firebase database.
             let duetUser = DuetUser(firstName: firstName,
                                     lastName: lastName,
                                     emailAddress: email)
             DatabaseManager.shared.insertUser(with: duetUser) { success in
                 if success {
                     // upload image
                     guard let image = strongSelf.imageView.image,
                           let data = image.pngData() else {
                        return
                    }
                    let fileName = duetUser.profilePictureFileName
                    StorageManager.shared.uploadProfilePicture(with: data, fileName: fileName) { result in
                        switch result {
                        case .success(let downloadUrl):
                            UserDefaults.standard.set(downloadUrl, forKey: "profile_picture_url")
                            print(downloadUrl)
                        case .failure(let error):
                            print("Storage manager error: \(error)")
                        }
                    }
                }
            }
            // go back to ConversationViewController.
            strongSelf.dismiss(animated: true, completion: nil)
        }
    })
 }

 

 

회원가입 전체로직 상세설명

DatabaseManager.shared.userExists(with: email, completion: { [weak self] exists in
        guard let strongSelf = self else {
            return
        }
        
        // when exists is False.
        guard !exists else {
            strongSelf.alertUserLoginError(message: "Looks like a user account for that email address already exists.")
            return
        }
 ...

위에서 만들었던 DatabaseManager 클래스의 userExists를 가장 먼저 사용한다. 이 함수는 인자로 받는 이메일을 Firebase Database의 데이터와 대조해 회원가입 가능유무를 체크한다. 이때 이메일이 이미 사용이 된 상태이면 completion의 인자 (위 코드에서는 exists)에 true가 리턴되고, 그렇지 않다면 false가 리턴된다.

 

따라서 exist 가 true 일 때, 이메일을 사용할 수 없다는 경고창을 띄우고 함수흐름을 종료해야 한다.

 

[weak self] 는 강한 참조를 벗어나기 위해 self를 Optional로 처리한다. parent Property를 참고할 일이 있으므로 사용하는게 메모리 누수를 예방하는데 좋다.

    // create user information first, then result or error will be returned.
    FirebaseAuth.Auth.auth().createUser(withEmail: email, password: password) { (authResult, error) in
        guard authResult != nil, error == nil else {
            print("Error creating user")
            return
        }
             
        // if result is good to go, user information should be inserted in firebase database.
        let duetUser = DuetUser(firstName: firstName,
                                lastName: lastName,
                                emailAddress: email)

 

이메일이 사용가능하므로 이제 회원가입 시 기재했던 이메일과 비밀번호를 가지고 Firebase Authentication 에 저장한다. 이 과정은 굉장히 간단하여 모듈이 필요하지 않다. 그냥 Firebase Auth가 제공하는 createUser(withEmail:password:completion:)를 사용하면 곧장 Auth에 인증객체가 저장된다. 

 

completion handler에는 인증객체생성 여부에 따라 FIRAuthDataResult 혹은 NSError가 전달되므로 error nil check를 통해 함수를 종료할지 지속할지 결정한다.

 

만약 Auth에 성공적으로 인증객체가 등록됐다면, 회원가입 시 기재했던 정보들로 Firebase Database에 등록할 유저객체를 만든다.

DatabaseManager.shared.insertUser(with: duetUser) { success in
    if success {
        // upload image
        guard let image = strongSelf.imageView.image,
              let data = image.pngData() else {
            return
        }
        let fileName = duetUser.profilePictureFileName
        StorageManager.shared.uploadProfilePicture(with: data, fileName: fileName) { result in
            switch result {
            case .success(let downloadUrl):
                UserDefaults.standard.set(downloadUrl, forKey: "profile_picture_url")
                print(downloadUrl)
            case .failure(let error):
                print("Storage manager error: \(error)")
            }
        }
    }
}

 

이제 만들어진 유저객체를 DatabaseManager의 인자로 넣어 Firebase Database에 insert한다. 이때 completion handler의 타입으로 @escaping (Bool) -> Void 을 지정했으므로 insertUser의 closuer argumen인 success에는 bool 값일 것이다. 우리는 true 일 경우만 필요하므로 조건문을 통해 유저정보가 Database에 저장됐는지 파악한다.

 

이제 회원이 등록한 이미지를 Storage에 넘겨주는 작업이 필요하다. 현재 view의 imageView에 이미지가 nil 이 아니고, 이 image가 pngData로 변환가능한지 판별하여 각각 변수에 저장한다 (image, data)

 

이후 fileName에 유저 구조체의 profilePictureFileName을 넘겨주고 있는데 이 profilePictureFileName은 아까 유저객체를 만들때 전달한 emailAddress 값 만으로 값이 변환되어 저장된 상태이다. 

 

<참고>

아래는 DatabaseManager에 저장되어 있는 User struct. safeEmail과 profilePictureFileName은 computed properties다.

struct DuetUser {
    let firstName: String
    let lastName: String
    let emailAddress: String
    
    // To solve issue that not allow some special symbols("[", "@", "," ...), exchange symbols in email like ".", "@" to "-"
    var safeEmail: String {
        var safeEmail = emailAddress.replacingOccurrences(of: ".", with: "-")
        safeEmail = safeEmail.replacingOccurrences(of: "@", with: "-")
        return safeEmail
    }
    
    var profilePictureFileName : String {
        return "\(safeEmail)_profile_picture.png"
    }
}

 

아무튼, profilePictureFileName을 얻었다면 아까 생성한 유저객체와 함께 StoreManager 클래스의 uploadProfilePicture 함수의 인자로 전달한다. 마찬가지로 성공유무에 따른 completion handler 인자가 리턴된다. 위의 다른 completion handler들과 다른점이 있다면 uploadProfilePicture의 completion을 구현할 때, 그 타입을 Result<String, Error> 로 지정했다는 것이다. 따라서 closure 인자 result는 Result 타입이기에 .success 와 .failure 케이스로 분기하여 사용할 수 있다.

 

성공시에는 result의 인자값으로 String이 전달되므로 이를 UserDefaults에 넣어 영속적으로 사용가능하게 만들면 된다.

 

 

 

이로써 Firebase Auth, Firebase Database, Firebase Storage를 모두 사용한 회원가입 로직이 완성됐다.