milennium9의 모든 글

[Docker] Ubuntu image timezone 설정

Docker에서 서버를 기동할 경우 기본적으로 UTC 시간이 Locatime으로 설정됩니다.

제가 사용했던 ubuntu:bionic 버전에 서버를 올린 경우 timezone을 설정하는 과정을 설명합니다.

우선 Timezone을 설정하기 위해서는 tzdata가 apt를 통해 설치되어야 합니다.

FROM ubuntu:bionic
WORKDIR /server
COPY . .

RUN apt-get update &&\
 apt-get install -y  tzdata

CMD ["/server/run"]

이후 docker image를 실행 시 "TZ" 환경 변수에 Timezone 정보를 설정해 줍니다.

version: "3.9"

service:
  server:
    image: myserver
    environment:
      TZ: Asia/Seoul

Docker compose에서는 위와 같이 환경변수를 설정해주면 실행 시 Timezone이 설정됩니다.

[Postgresql] postgres_fdw

Postgresql에서는 여러 DB instance 사이에 join이 기본적으로 불가능 합니다.

이를 위해서 몇가지 extension이 존재하는데 dblink와 postgres_fdw 입니다.

dblink는 쿼리에서 DB instance의 Host 주소와 계정 정보를 입력하여 잠깐동안의 임시 연결을 생성하는 방식이고
postgres_fdw의 경우에는 연결 설정을 저장 해 두고 schema를 통기화 하여 사용하는 방식으로 dblink에 비해서 성능이 좀 더 좋습니다.

당연한 이야기지만 외부 db를 join 하는 것은 기본적으로 하나의 DB내 테이블을 Join 하는 것과 비교해서 크게 느리기 때문에 자주 Join을 해야하는 데이터라면 같은 DB Instance에 저장하도록 설계해야 합니다.

CREATE EXTENSION IF NOT EXISTS postgres_fdw
SCHEMA public;

우선 위 쿼리를 통해서 postgres_fdw를 설치 해 줍니다.
AWS에서도 지원하기 때문에 그냥 설치하면 됩니다.

CREATE SERVER server_name
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS(
  host 'target server ip or url',
  port '5432',
  dbname 'target database name'
);

그 다음 연결할 instance를 server로 추가해 줍니다.

CREATE USER MAPPING
FOR public
SERVER server_name
OPTIONS(
  user 'target db instance user',
  password 'target db instance user password'
);

이 원격 DB Instance에 접근할 때 사용될 사용자 계정을 Mapping 해 줍니다.

IMPORT FOREIGN SCHEMA public
FROM SERVER foreign_server INTO schema_name;

그 다음 원격 DB Instance에서 연결할 Table들을 정의해 줘야 하는데, 하나하나 따로 정의할 필요가 없다면 위 쿼리를 통해서 전체 스키마를 퍼옵니다.
schema_name에 해당하는 스키마는 미리 생성해야 하며 public에 추가해도 무방하지만 EF Core를 사용하는 경우 Migration 테이블이 중첩되어 실패할 수 있기 때문에 해당 DB Instance의 이름에 맞는 Schema를 추가해서 Import를 하면 이후 편하게 사용이 가능합니다.

[C#] Linq – Left outer join (with Method chain)

var characters = new[] {
    new Character { name = "Hero", inventory_id    = 1 },
    new Character { name = "Heroine", inventory_id = 2 },
};

var items = new[] {
    new Item { inventory_id = 2, name = "Weapon" },
    new Item { inventory_id = 2, name = "Armor" },
};

var joined = characters
    .GroupJoin(
        items,
        c => c.inventory_id,
        i => i.inventory_id,
        (character, item) => (character, item: item.DefaultIfEmpty()));

foreach (var elem in joined) {
    foreach (var ci in elem.item) {
        WriteLine($"{elem.character.name} has {ci?.name ?? "no item"}");
    }
}

Output

Hero has no item
Heroine has Weapon
Heroine has Armor

[C#] DateTime Localization

어플리케이션을 글로벌 하게 서비스 하게되면 서버의 시간과 클라이언트 시간이 일치하지 않게 됩니다. 그래서 클라이언트에 ServerTime class를 만들어서 서버 시간을 받아서 처리하는 경우들이 있습니다. 서버에 접속하면 현재 서버 시간을 클라이언트에 전송해 주고 상점에서 상품 판매나 이벤트 시간등을 서버 시간을 토대로 처리하면 전세계 사용자들이 동일한 타이밍에 시간 처리가 가능합니다.
물론 가장 이상적인 것은 각 사용자별로 현재 자신의 시간에 맞춰서 이벤트가 진행되는게 가장 좋겠지만 의도적으로 디바이스 시간을 조정해서 보상을 받거나 어뷰징을 하는 사용자가 존재하기 때문에 그 방법을 안전하게 처리하는 것은 고민이 좀 필요한 부분입니다.

패킷으로 DateTime 타입을 전송할 때 ToBinary() 함수를 사용합니다.
문제는 서버에서 DateTime.Now.ToBinary() 를 사용하여 시간을 전송하고 클라이언트가 FromBinary로 받게되면 서버 시간을 받는게 아니라 클라이언트 시간을 그대로 받게 됩니다.
서버는 분명이 2:00PM 인데 받는 클라이언트는 자신의 시간대인 6:00PM이 되어버리는 경우가 발생해 버리는 것이죠.

DateTime이 이런 부분까지 신경을 쓰고 있다고 생각은 안 했지만 의외로 내부적으로 Kind라는 Enum을 이용하여 시간에 대한 처리를 하고 있었습니다.
https://docs.microsoft.com/ko-kr/dotnet/api/system.datetime.kind?view=net-5.0

var local = DateTime.Now;
var utc = DateTime.UtcNow;
var unspecified = new DateTime(2021, 4, 4);

위의 코드를 보면 변수명에 적혀 있듯 각각의 DateTime객체에 대한 Kind는 Local, Utc, Unspecified 가 됩니다.
ToBinary()를 이용해서 시차가 있는 다른 기기에서 해당 값을 FromBinary()로 읽어들이게 되면 Kind에 따라서 표시되는 시간이 달라집니다.
Local은 OS에 지정된 TimeZone 설정에 맞춰서 시간이 바뀝니다. 보내는 쪽에서는 한국 시간을 보냈지만 받는 곳이 미국이면 미국 시간으로 표시됩니다. UTC 시간을 기준으로 TimeZone 시차만큼을 계산해서 표시를 해주기 때문입니다.
즉, 모든 DateTime은 내부적으로 Utc 시간을 가지고 있고 Kind가 Local인 경우에는 값을 참조할 때 OS의 TimeZone설정에 따라 시차를 더해서 계산해 주는 것입니다.
같은 이치로 Kind가 Utc인 경우에는 전송한 곳이나 전송 받은 곳이나 기준이 동일하기 때문에 동일한 시간을 표시할 것입니다. 그래서 서버는 Utc 시간을 사용하라는 내용을 많은 곳에서 찾아볼 수 있습니다.
마지막으로 Unspecified는 사용자가 DateTime의 Now, UtcNow 같은 메소드를 사용해서 시간을 받아온게 아니라 임의로 날짜를 명시하여 생성한 경우입니다. 객체가 생성될 때 시간을 지정해 주지만 Kind를 명시해 주지 않으면 이게 지역 시간인지 Utc인지 알 방법이 없습니다. 그래서 Kind로 Unspecified를 지정해 둡니다. 이 시간은 전송할 때 보내는 쪽이나 받는쪽 모두에서 시간 표시가 그대로 유지됩니다.

여기에서 문제는 Server를 구현할 때 DateTime의 Kind를 염두에 두지 않고 구현을 하게되면 클라이언트가 전송 받은 시간의 기준이 완전히 제각각이 됩니다.
가장 대표적인 케이스가 Database에 저장되어 있던 시간을 받아온 것과 DateTime.Now를 통해서 새로 생성한 데이터는 Kind가 다르게 설정되지만 서버에서는 큰 문제가 없어보이지만 클라이언트로 시간을 전송해보면 엄청난 혼란을 가중 시키는 대표적인 예시입니다.

[Unity] iOS 에서 Http 다운로드 안되는 현상

Unity 2018.4.18f1 버전을 기준으로 정리합니다.

Unity에서 iOS 빌드를 했을 때 보통 AssetBundle을 다운로드 받기 위해서는 https가 아닌 http 프로토콜을 활용합니다.
무슨 보안 정보도 아닌데 https 씩이나 사용할 이유가 없으니까요.
그런데 iOS는 Secure하지 않다는 이유로 앱에서 Http 프로토콜을 사용하는 것을 기본적으로 차단하고 있습니다.
그렇기 때문에 아래와 같이 다운로드를 수행할 때 실패하게 됩니다.

var download = UnityWebRequest.Get("http://download.game.com/assetbundle");
yield return download.SendWebRequest();

if( download.isHttpError ) {
  Debug.LogError($"ResponseCode : {download.responseCode}");
}
else if( www.isNetworkError ) {
  Debug.LogError($"Error : {download.error}");
}
else {
  Debug.Log("Download Success");
}

위의 코드를 iOS에서 실행하면 "Error : Unknown Error" 라고 출력이 됩니다.

에러가 나면 왜 에러가 났는지를 메시지로 알려줘야 하는데 밑도끝도 없이 Unknown Error라고 해버리니 매우 답답하기 그지없는 상황이 됩니다.

iOS에서 Http를 사용하려면 Unity에서 빌드 후 XCode 프로젝트에서 info.plist를 오픈하여 App Transport Security Settings를 수정해 줘야 합니다.
그런데 문제는 Unity Project Setting에서 Allow downloads over HTTP 옵션을 설정해주면 info.plist에 자동으로 아래의 두가지 옵션을 추가해 줍니다.
- Allow Arbitrary Loads : YES
- Allow Arbitrary Loads in Web Content : YES

위의 옵션에 대한 자세한 내용은 아래의 링크를 참조하면 됩니다.
https://developer.apple.com/documentation/bundleresources/information_property_list/nsapptransportsecurity

문서상으로는 NSAllowArbitraryLoads를 true로 설정하면 Http 프로토콜의 사용이 허용된다고 되어 있습니다.
그런데 NSAllowArbitraryLoads는 전에 Http 프로토콜을 오픈해 버리는 것이기 때문에 여러가지 하위 옵션들을 제공하고 있습니다.
NSAllowsArbitraryLoadsForMedia, NSAllowsArbitraryLoadsInWebContent, NSAllowsLocalNetworking과 같은 옵션이 그렇습니다.

문제는 iOS 9.0과 iOS 10.0 이후의 동작이 약간 다릅니다. 이게 아주 심각한 문제와 혼란을 초래할 수 있습니다.

NSAllowArbitraryLoads는 Http 동작 전체를 허용하거나 허용하지 않는 최상위 옵션이고 기본값은 false입니다.
하지만 NSAllowArbitraryLoads와 동시에
NSAllowsArbitraryLoadsForMedia, NSAllowsArbitraryLoadsInWebContent, NSAllowsLocalNetworking 옵션이 설정되어 있을 때의 동작은 iOS 9.0 이전과 iOS 10.0 이후의 동작이 달라집니다.

iOS 9.0 이전에는 NSAllowArbitraryLoads가 true이면 하위 옵션들의 설정을 무시합니다. 최상위 옵션이니까 허용했으면 걍 다 허용되는 것이죠.

iOS 10.0 이후부터는 NSAllowArbitraryLoads가 true인데 하위 옵션들이 있으면 최상위 옵션을 무시합니다. 즉 NSAllowArbitraryLoads가 false가 되어버리는 것입니다. 왜냐하면 보안 안정성을 올리는 것이 이 옵션의 목적인데 최상위 하나만 설정하는 것으로 보안 설정을 모두 뚫어버리니까 하위 옵션이 설정되면 최상위 옵션을 내리고 좁은 범위의 허용을 우선하겠다는 목적입니다.

그런데 Unity는 Unity Ads를 활성화 한 상태로 빌드하면 NSAllowArbitraryLoads와 NSAllowArbitraryLoadsInWebContent가 동시에 설정됩니다. 그러면 NSAllowArbitraryLoads가 무시되어 버리면서 Http 프로토콜이 막혀 버립니다.
하지만 plist 옵션을 확인해 보면 NSAllowArbitraryLoads가 true로 설정되어 있으니 괜찮아야 할 것 같아서 다른 원인들을 찾아 헤메게 됩니다.

해결책은 NSAllowArbitraryLoads외의 하위 옵션들을 지워주기만 하면 됩니다.

이틀간의 삽질의 기록을 이렇게 남깁니다....

[Unity] Apple Login과 Firebase 인증

Apple이 익명 로그인 기능만 사용할게 아니라면 Apple Login을 의무화 하였기 때문에 Apple Login을 지원하지 않으면 리젝을 당하게 됩니다.

그래서 Apple Login을 프로젝트에 추가하는 과정을 간략하게 남깁니다.

우선 Unity에서 애플 로그인을 지원하는 플러그인이 두 가지가 있습니다.
첫번째는 Unity Technology에서 제공하는 SignInWithApple 입니다.
https://assetstore.unity.com/packages/tools/sign-in-with-apple-154202

두번째는 GitHub에 올라와 있는 AppleAuth 입니다.
https://github.com/lupidan/apple-signin-unity

당연히 처음에는 Unity에서 제공해 주는 플러그인을 사용하여 구현을 진행하였고 Apple Login이 문제없이 동작하는 것을 확인하였으나 Firebase 인증 단계에서 벽에 부딪혔습니다.
Firebase 인증 단계에서 Apple Login의 검증을 위해 nonce를 요구하는데, SignInWithApple은 Nonce을 얻어오는 API가 제공되지 않았습니다.
(현재 시점에 제가 발견하지 못했을 수 있습니다.)
그래서 눈물을 머금고 코드를 날린 뒤 AppleAuth를 통해 구현을 진행했습니다.

AppleAuth 설치

GitHub의 설명대로 진행하면 됩니다.
저는 2018.4 버전을 사용하고 있기 때문에 Packages/manifest.json 파일을 열어 아래의 패키지를 추가해 줍니다.

"dependencies": {
    "com.lupidan.apple-signin-unity": "https://github.com/lupidan/apple-signin-unity.git#v1.1.0",
}

AppleAuthorizer 구현

using AppleAuth;

public class AppleAuthorizer {
    // 취향 상 싱글턴을 사용합니다.
    public static AppleAuthorizer instance {get; private set;}

    public static string IdToken {get; private set;}
    public static string AuthCode {get; private set;}
    public static string RawNonce {get; private set;}
    public static string Nonce {get; private set;}

    private IAppleAuthManager appleAuthManager;
    public bool IsLoginSuccess = false;
    public bool IsLoginDone = false;

    private void Awake() {
        if( _instance == null && _instance != this) {
            Destroy(gameObject);
            return;
        }
        _instance = this;
        DontDestroyOnLoad(gameObject);
    }
    private void Start() {
        if(AppleAuthManager.IsCurrentPlatformSupported) {
            var deserializer = new PayloadDeserializer();
            appleAuthManager = new AppleAuthManager(deserializer);
        }
    }
    private void Update() {
        appleAuthManager?.Update();
    }

    // Nonce는 SHA256으로 만들어서 전달해야함
    private static string GenerateNonce(string _rawNonce) {
        SHA256 sha = new SHA256Managed();
        var sh = new StringBuilder();
        // Encoding은 반드시 ASCII여야 함
        byte[] hash = sha.ComputeHash(Encoding.ASCII.GetBytes(_rawNonce));
        // ToString에서 "x2"로 소문자 변환해야 함. 대문자면 실패함. ㅠㅠ
        foreach (var b in hash) sb.Append(b.ToString("x2"));
        return sb.ToString();
    }
    // 내부적으로 사용하는 인터페이스 통일을 위해 Coroutine으로 구현
    // 기존 인터페이스가 아니었다면 Async를 사용했을 듯
    public IEnumerator LoginProcess() {
        IsLoginSuccess = false;
        IsLoginDone = false;

        // Nonce 초기화
        // Nonce는 Apple로그인 시 접속 세션마다 새로 생성
        RawNonce = System.Guid.NewGuid().ToString();
        Nonce = GenerateNonce(RawNonce);

        // QuickLogin을 먼저 수행
        // 이전 로그인 기록이 없다면 실패 처리됨
        var quickLoginArgs = new AppleAuthQuickLoginArgs(Nonce);
        var isQuickLoginDone = false;
        appleAuthManager.QuickLogin(
            quickLoginArgs,
            credential => {
                try {
                    var appleIdCredential = credential as IAppleIDCredential;
                    AuthCode = Encoding.UTF8.GetString(appleIdCredential.AuthorizationCode);
                    IdToken = Encoding.UTF8.GetString(appleIdCredential.IdentityToken);
                    IsLoginSuccess = true;
                }
                catch(System.Exception e) {
                    Debug.LogException(e);
                    IsLoginSuccess = false;
                }
                isQuickLoginDone = true;
            },
            error => {
                IsLoginSuccess = false;
                isQuickLoginDone = true;
            });
        yield return new WaitUntil(() => isQuickLoginDone);
        // QuickLogin이 성공했다는 것은 이전 로그인 정보가 있었다는 의미
        // 일반 Login 과정을 진행할 필요가 없어짐.
        if(IsLoginSuccess) {
            IsLoginDone = true;
            yield break;
        }

        var loginArgs = new AppleAuthLoginArgs(LoginOptions.IncludeEmail, Nonce);
        appleAuthManager.LoginWithAppleId(
            loginArgs,
            credential => {
                try {
                    var appleIdCredential = credential as IAppleIDCredential;
                    AuthCode = Encoding.UTF8.GetString(appleIdCredential.AuthorizationCode);
                    IdToken = Encoding.UTF8.GetString(appleIdCredential.IdentityToken);
                    IsLoginSuccess = true;
                }
                catch(System.Exception e) {
                    Debug.LogException(e);
                    IsLoginSuccess = false;
                }
                IsLoginDone = true;
            },
            error => {
                IsLoginSuccess = false;
                IsLoginDone = true;
            });
        yield return new WaitUntil(() => IsLoginDone);
    }
}

위의 코드는 프로젝트에서 사용하는 인증 코드의 일부를 떼어낸 것이라 컴파일 오류가 날 수 있으나 기본적인 로직은 동일하기 때문에 사용 가능할 것이라고 생각됩니다.
QuickLogin에 대한 처리는 기존 로그인 정보가 있는지 여부를 별도로 파악해야할지 그냥 QuickLogin을 실패처리하면 될지 테스트가 필요한 상태.

Firebase에서 AppleLogin 사용

Firebase 쪽 코드는 잡코드가 많아서 Apple Login에 사용되는 부분만 추렸다.

// Firebase는 Google이나 Apple GameCenter와 달리 AppleLoginProvider를 제공하지 않고 있다. 그래서 OAuthProvider를 사용해야한다.
// RawNonce는 SHA256으로 변환하기 전 문자열을 의미한다.
var credential = Firebase.Auth.OAuthProvider.GetCredential("apple.com", AppleAuthorizer.IdToken, AppleAuthorizer.RawNonce, AppleAuthorizer.AuthCode);

var task = auth.SignInWithCredentialAsync(credential);
yield return new WaitUntil(() => task.IsCompleted || task.IsFaulted || task.IsCanceled);

FirebaseUser user = task.Result;

주의할 것은 AppleLogin 과정에서는 SHA256으로 만들어진 Nonce를 전달해야하고 Firebase 인증 시에는 SHA256으로 변환하기 전의 문자열을 전달해야 한다.

XCode 설정

AppleLogin을 위한 XCode 설정은 Unity Blog에서 영상으로 친절하게 설명하고 있으니 해당 내용을 참고하면 된다.
https://blogs.unity3d.com/kr/2019/09/19/support-for-apple-sign-in/

Reference

https://firebase.google.com/docs/auth/ios/apple?authuser=0
https://github.com/lupidan/apple-signin-unity
https://firebase.google.com/docs/auth/unity/apple

기타 삽질

UIApplicationExitsOnSuspend가 plist에 포함되어 있으면 ipa 업로드 중 에러남.
plist 열어서 삭제해 줘야함.

[Unity] TextMeshPro 코드 버전을 Dll 버전으로 변경

이번에 프로젝트의 버전을 2017.4에서 2018.4 버전으로 업데이트 하면서 예전에 구매해서 사용중이던 TextMeshPro의 버전업도 진행하게 되었습니다.
문제는 Unity에서 TextMeshPro를 포함하기 전에 구매한 유저들은 SourceCode가 포함된 형태로 사용 중이었는데 Unity에서 기본 배포하게 되면서 Dll 형태로 배포되기 시작했습니다.
그래서 Unity에서 배포중인 패키지를 설치하게 되면 소스코드와 Dll이 충돌하게 되고 소스코드를 삭제하면 Scene에 사용중이던 TextMeshPro Component들이 전부 Missing 상태로 변경됩니다.
Python으로 Scene, Prefab의 내용을 직접 수정하는 툴을 작업하고 있었는데 알고보니 TextMeshPro에서 기본적으로 그러한 기능을 제공하고 있었습니다.

이번에 버전업을 했던 절차를 정리하면 아래와 같습니다.

  • 2018.4로 버전업을 진행했습니다.
  • Package Manager에서 TextMeshPro를 설치합니다.
  • TextMeshPro Dll과 코드가 중복됨으로 인해서 에러가 엄청나게 발생하고 있습니다. 기존에 사용하던 TextMeshPro 폴더를 삭제합니다.
  • Asmdef 기능을 사용하고 있다면 Dll의 참조가 안됨으로 인해서 에러가 발생하고 있습니다. Asmdef에 새로운 TextMeshPro 참조를 추가해 줍니다.
  • 그래도 에러가 발생하고 있다면 TextMeshPro API 변경으로 인한 것이므로 코드 수정을 통해서 해결해 줍니다.
    • TMP_FontUtilities.SearchForGlyph API가 없어졌습니다. TMP_FontUtilities.SearchForCharacter로 변경해 줬습니다.
    • 임의로 수정해서 사용하던 OnTextChanged 이벤트가 없어서 havePropertiesChanged Property가 true가 되었을 때 LateUpdate() 함수에서 변경을 체크하도록 수정하였습니다.
  • Window - TextMeshPro - Project Files GUID Remapping Tool 을 실행해 줍니다.
  • Scan Project Files 버튼을 클릭하여 Scene과 Prefab, Font Asset, Font Material 파일들을 검색하여 Package Manager로 추가한 새 TextMeshPro 스크립트를 참조하도록 갱신해 줍니다.
  • 검색 후 Save Modified Project Files 를 클릭해 주면 문제가 깔끔하게 사라지면서 새 버전의 TextMeshPro를 사용할 수 있게 되었습니다.

[Unity3D] Android 빌드에서 “Failure to Initialize! Your hardware does not support this application, sorry!” 라고 출력되면?

어느 날 갑자기 매일 하던대로 빌드를 해서 APK를 뽑아 설치를 하고 실행을 했더니

Failure to Initialize! Your hardware does not support this application, sorry!

 

라고 출력되었습니다.

이유는 모르겠지만 그냥 갑자기 발생하기 시작했습니다.

검색해보니 원인은 굉장히 다양한 것 같지만....

 

[해결책]

Play Service Resolver에서 Force Resolve를 한번 수행한 뒤에 빌드를 다시하니까 괜찮아졌습니다.

[C#] using 문 내부에서 yield return을 하면?

Disposable객체를 Coroutine 내부에서 사용할 경우 과연 어느 타이밍에 파괴될지 궁금해서 한번 간단하게 테스트 코드를 작성해 봤습니다.

설마... yield return 할 때 마다 파괴되고 다시 생기진 않겠지...

테스트 하는 김에 using static 도 한번 사용해 봅니다.

using System;
using System.Collections;
using static System.Console;

namespace YieldReturnInUsingStatement
{
    class DisposeTest : IDisposable
    {
        public DisposeTest() {
            WriteLine("Construct");
        }
        public void Dispose() {
            WriteLine("Dispose");
        }
    }
    class Test
    {
        public IEnumerator Run() {
            using (new DisposeTest()) {
                for (int i = 0; i < 10; ++i) {
                    WriteLine(i);
                    yield return null;
                }
            }
        }

        public Test() {
            var e = Run();
            while (e.MoveNext()) {

            }
        }
    }
    class Program
    {
        static void Main(string[] args) {
            var test = new Test();
        }
    }
}

결과는 다행히 using 블록을 완전히 빠져나간 타이밍에 Dispose 되었습니다.

Construct
0
1
2
3
4
5
6
7
8
9
Dispose

 

using static은 참 편하네요. 전역 함수처럼 만들어 줍니다.

많이 쓰면 몸에 나쁠것만 같은 느낌.