개발 중이던 모바일 게임에서 로그인 기능을 파이어베이스를 활용해 제작하려던 중, 유니티 6와 파이어베이스의 연동이 불안정해 의도치 않은 오류가 난다는 말들이 많다는 사실을 깨달았다.
실제로 적용 중 연동이 안되는 문제를 만나 다른 방법을 모색하던 중 배우게 된 스프레드시트를 활용한 로그인 방법에 대해 설명하려고 한다.
(개인 실수로 인해 적용이 안됐을 수도 있다는 점은 참고 바란다)
데이터베이스를 통해 저장하고 보내야 하는 정보로는 5가지를 세팅했다.
1. 아이디
2. 비밀번호
3. 닉네임
4. 승
5. 패
이를 저장하기 위한 구글 스프레드시트 환경을 먼저 세팅해보도록 하자

이런 식으로 필요한만큼 텍스트를 나누어 저장해놓은 후
일단 전체 공유를 만들어둔 후 이런저런 설정을 거쳐야 한다.
위 사진 쪽 확장 프로그램 탭을 눌러보면

Apps Script 탭이 존재한다
여기서 이 시트를 어떤 방식으로 전달 및 받을지에 대해 조정하게 된다.
그럼 먼저 이런식으로 권한을 열어둔 후 유니티에서 세팅을 해보자
네트워크를 열어두고
시트를 보유한 사이트를 저런 방식으로 범위까지만 떼서 붙여넣는다

이런 식으로 사이트를 연결해둔다.
그런데 권한을 열어두면 아이디와 비밀번호를 모두에게 공개해버리는 꼴이 되니 권한을 닫아줘야 한다.
무조건 나만 볼 수 있게 다시 공유 탭으로 가 권한을 닫은 후 다른 처리를 해주어야 한다.
방금 올린 AppsScript에 접속하면 이런 화면이 뜰 것이다.

이를 처음 실행하려 할 때 권한 요구가 뜰 텐데 다 허가해주면 연동이 가능하다.
이제 우리가 해야 할 것은 크게 3가지가 존재한다.
1. 입력한 회원가입 정보(아이디, 비밀번호, 닉네임)를 받아 스프레드시트에 넘기기
2. 로그인 시 정보 진위여부 파악 후 확인되면 닉네임 정보 뱉기
3. 승패 시 시트에 실시간 업데이트
일단 AppsScript 코드는 이렇게 진행했다
var sheetId = SpreadsheetApp.openById("1Pk5biu6Y7dWJdqNcO_KvMmsEbij61nv0SGBpi1w1iRU");
var sheet = sheetId.getSheets()[0];
var logSheet = sheetId.getSheets()[1];
var cache = CacheService.getUserCache();
var p;
var result, msg, nickname;
function removeCache()
{
cache.removeAll(["id", "row"]);
}
function response()
{
var json = {};
json.order = p.order;
json.result = result;
json.msg = msg;
json.nickname = nickname;
if (p.order == "login") {
let row = cache.get("row");
json.win = String(sheet.getRange(row, 4).getValue() || 0);
json.lose = String(sheet.getRange(row, 5).getValue() || 0);
}
var jsonData = JSON.stringify(json);
return ContentService.createTextOutput(jsonData);
}
function regCheck(val)
{
var regExp = /[\{\}\[\]\/?.,;"|\)*~`!^\-+<>@\#$%&\\\=\(\'\"]/gi;
if(regExp.test(val)) return false;
else return true;
}
function doPost(e)
{
p = e.parameter;
if(!regCheck(p.order) || !regCheck(p.id) || !regCheck(p.pass))
return ContentService.createTextOutput("");
switch(p.order)
{
case "register": removeCache(); register(); break;
case "login": removeCache(); login(); break;
case "logout": removeCache(); break;
case "setValue": setValue(); break;
case "getValue": getValue(); break;
case "saveStats": saveStats(); break;
}
return response();
}
function setResult(_result, _msg)
{
result = _result;
msg = _msg;
}
function register()
{
var cell = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues(); // A열 (ID)
if (cell.some(row => row[0] == p.id))
{
setResult("ERROR", "이미 존재하는 아이디입니다");
return;
}
sheet.appendRow([p.id, p.pass, p.nickname]); // 닉네임 추가
setResult("OK", "회원가입 완료");
}
function getProfile()
{
var cell = sheet.getRange(2, 1, sheet.getLastRow() - 1 , 3).getValues(); // A~C 열 (ID, PW, 닉네임)
for (let i = 0; i < cell.length; i++)
{
if (cell[i][0] != p.id || cell[i][1] != p.pass)
continue;
cache.put("id", cell[i][0]);
cache.put("row", (i + 2).toString());
nickname = cell[i][2]; // 닉네임 저장
return true;
}
return false;
}
function login()
{
if(!getProfile())
{
setResult("ERROR", "로그인 실패");
return;
}
setResult("OK", "로그인 완료");
let row = cache.get("row");
let win = sheet.getRange(row, 4).getValue();
let lose = sheet.getRange(row, 5).getValue();
}
function saveStats()
{
let row = cache.get("row");
if (!row) {
setResult("ERROR", "로그인 정보 없음");
return;
}
sheet.getRange(row, 4).setValue(p.win);
sheet.getRange(row, 5).setValue(p.lose);
setResult("OK", "승/패 저장 완료");
}
function setValue()
{
if(cache.get("row") == null)
{
setResult("ERROR", "다시 로그인 해주세요");
return;
}
sheet.getRange(cache.get("row"), 3).setValue(p.value);
setResult("OK", cache.get("id") + "님이 " + p.value + "값 저장 완료");
}
function getValue()
{
if(cache.get("row") == null)
{
setResult("ERROR", "다시 로그인 해주세요");
return;
}
value = sheet.getRange(cache.get("row"), 3).getValue();
setResult("OK", cache.get("id") + "님이 값 불러오기 완료");
}
짧게 설명하자면 일단 5개의 정보가 들어갈 공간을 만들고
이를 Regisgter, GetProfile, saveStates 등의 함수를 통해 연결을 하는 개념이다.
유니티 쪽 코드는
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
[System.Serializable]
public class GoogleData
{
public string order, result, msg, value;
public string nickname;
public string win;
public string lose;
}
public class GoogleSheetManager : MonoBehaviour
{
const string URL = "https://script.google.com/macros/s/AKfycbychVwcdwIya2x_cr7LDXKpdmuus6zHXDIfKj2jeciA08kL_9D84NuqwkAulvrxKsty/exec";
public GoogleData GD;
public TMP_InputField SignIDInput, SignNickInput, SignPassInput; // 회원가입용
public TMP_InputField LogIDInput, LogPassInput; // 로그인용
public TextMeshProUGUI StatusText;
public Login login;
public static string LoggedInNickname;
// public TMP_InputField ValueInput;
string id, pass;
bool GetSignIDPass(out string id, out string pass)
{
id = SignIDInput.text.Trim();
pass = SignPassInput.text.Trim();
return !(string.IsNullOrEmpty(id) || string.IsNullOrEmpty(pass));
}
bool GetLogIDPass(out string id, out string pass)
{
id = LogIDInput.text.Trim();
pass = LogPassInput.text.Trim();
return !(string.IsNullOrEmpty(id) || string.IsNullOrEmpty(pass));
}
bool IsValidPassword(string password)
{
bool hasLetter = false;
bool hasDigit = false;
foreach (char c in password)
{
if (char.IsLetter(c)) hasLetter = true;
if (char.IsDigit(c)) hasDigit = true;
}
return hasLetter && hasDigit;
}
public void Register()
{
if (!GetSignIDPass(out id, out pass))
{
StartCoroutine(login.ShowStatusText("ID or Password is empty", 1f));
return;
}
string nickname = SignNickInput.text.Trim();
if (string.IsNullOrEmpty(nickname))
{
StartCoroutine(login.ShowStatusText("Nickname is empty", 1f));
return;
}
if (id.Length < 8)
{
StartCoroutine(login.ShowStatusText("ID must be at least 8 characters", 1f));
return;
}
if (!IsValidPassword(pass))
{
StartCoroutine(login.ShowStatusText("Password must include letters and numbers", 1f));
return;
}
WWWForm form = new WWWForm();
form.AddField("order", "register");
form.AddField("id", id);
form.AddField("pass", pass);
form.AddField("nickname", nickname);
StartCoroutine(Post(form));
}
public void Login()
{
if (!GetLogIDPass(out id, out pass))
{
StatusText.color = Color.red;
StartCoroutine(login.ShowStatusText("ID or Password is empty", 1f));
return;
}
WWWForm form = new WWWForm();
form.AddField("order", "login");
form.AddField("id", id);
form.AddField("pass", pass);
StartCoroutine(Post(form));
}
void OnApplicationQuit()
{
WWWForm form = new WWWForm();
form.AddField("order", "logout");
StartCoroutine(Post(form));
}
public void SetValue()
{
WWWForm form = new WWWForm();
form.AddField("order", "setValue");
StartCoroutine(Post(form));
}
public void GetValue()
{
WWWForm form = new WWWForm();
form.AddField("order", "getValue");
StartCoroutine(Post(form));
}
IEnumerator Post(WWWForm form)
{
using (UnityWebRequest www = UnityWebRequest.Post(URL, form)) // 반드시 using을 써야한다
{
yield return www.SendWebRequest();
if (www.isDone) Response(www.downloadHandler.text);
else print("Web No.");
}
}
void Response(string json)
{
if (string.IsNullOrEmpty(json))
{
StartCoroutine(login.ShowStatusText("No Server response", 1f));
StatusText.color = Color.red;
return;
}
GD = JsonUtility.FromJson<GoogleData>(json);
if (GD.result == "ERROR")
{
StartCoroutine(login.ShowStatusText(GD.msg, 1f));
StatusText.color = Color.red;
Debug.LogWarning(GD.order + " 실패: " + GD.msg);
return;
}
// 성공 시
// 로그인 성공 시
if (GD.order == "login")
{
int win = int.Parse(GD.win);
int lose = int.Parse(GD.lose);
LoggedInNickname = GD.nickname; // 닉네임은 여기에만 저장
UserInfo.Instance.SetUserInfo(win, lose); // 승패만 넘김
StartCoroutine(login.ShowStatusText($"Login Success! Hello {LoggedInNickname}", 1f));
StartCoroutine(GoToLoadingScene());
}
else if (GD.order == "register")
{
StartCoroutine(login.ShowStatusText("Register Succes!", 1f));
}
else
{
StartCoroutine(login.ShowStatusText($"{GD.order} 성공!", 1f));
}
StatusText.color = Color.green;
}
IEnumerator GoToLoadingScene()
{
yield return new WaitForSeconds(1f);
SceneManager.LoadScene("LoadingScene");
}
public void SaveStats(int win, int lose)
{
WWWForm form = new WWWForm();
form.AddField("order", "saveStats");
form.AddField("win", win.ToString());
form.AddField("lose", lose.ToString());
StartCoroutine(Post(form));
}
}
이런 식으로 input에 회원가입, 로그인 정보를 받고, 이를 시트에 보내고 받은 후 출력하는 일련의 과정들을 전달하는 코드이다
출력 시 글자 색깔을 바꾸는 등의 부가적 기능은 참고만 해도 괜찮을 듯 하다.
승패는 다른 씬에서 업데이트가 되어야 하는 부분이기에 UserInfo라는 싱글톤 형식의 코드가 받아서 뿌려주는 방식을 고려중이다.
이런 식이면

자동으로 아이디, 비밀번호, 닉네임이 저장, 출력되며, 승패도 안정적으로 저장될 것으로 보인다.