OAuth2.0 PKCE 잘 구현하려다가 생애주기에 얻어맞은 썰
개요
C++, Python, Spring 모두 비동기 프로그래밍을 지원하지만 내 경험상 아직까지 비동기 코드를 제대로 작성해본 경험이 거의 없다시피 하다. 필요할 때마다 자료도 찾아보고 GPT 도움도 받아가면서 어지저찌 만들긴 했지만, 언 어떤 흐름으로 실행되고, 어떤 타이밍에서 꼬일 수 있는지 까지는 아직 확실하게 안다고 말하지 못하겠다.
마찬가지로 GC 개념에 대해서도, 알고는 있지만 자신있게 다룰 수 있는가를 물어본다면 선뜻 대답하기는 어렵다. 객체의 생명주기나 소멸되는 시점, 참조 유지와 같은 개념을 설명해보라고 하면 살짝 머뭇거리게 된다.
그런데.. 언리얼엔진에서는 이 두 가지가 모두 밥 먹듯이 등장한다. 게임업계로 가고싶은게 아닌 내 입장에서는 분명히 이런 작업을 해봐야 포트폴리오로 써먹기 힘든걸 알면서도... 취미로 꼭 해보고싶어서 시작한 언리얼엔진 덕분에 본의아니게 async, broadcast, RPC, 싱글톤 패턴을 본격적으로 폐관수련 중이다.
아무튼, 언리얼엔진용 SDK 개발 도중에 싱글톤 패턴의 콜백 서버 인스턴스를 관리하는 코드를 작성하다가 생애주기 관련 문제가 발생해서 디버깅에 많은 시간을 쏟았는데, 알고보니 정말 별 것 아닌 문제였다. 다시는 같은 실수로 시간 낭비하는 일이 없길 바라며 박제한다.
구현하려던 기능
작성중이던 코드는 다음의 역할을 수행해야 했다.
1. SSO 로그인 기능을 지원하기 위하여 PKCE 기반의 OAuth 2.0 인증용 Callback Server 인스턴스를 생성하고 HTTP 리스너를 바인딩한다.
2. `StartLogin()` 메서드는 PKCE 인증을 위한 챌린지와 csrfState 문자열을 생성하고 콜백서버를 띄운 뒤, 서버로 챌린지를 전달해야한다.
3. 사용자가 브라우저에서 로그인하고, 서버에서 redirect URL로 콜백이 돌아오면 콜백 서버가 캐치하여 verifier와 함께 토큰 발급을 요청한다.
4. 인증 성공!
다만, SDK를 만드는 입장에서, 한 번 성공할 수 있는 코드보다는, 여러 번 우다다다 눌러도 안 터지고, 중간에 실패해도 복구 가능하고, 디버깅도 쉬운 그런 코드로 구현할 의무감을 가지고 있었다. 그렇게 변태적으로 QA, 디버깅 해야겠다고 마음먹고 첫 구현이 끝나고 '로그인하기' 버튼을 광클해봤더니 바로 문제가 드러났다.
어떻게 해야 콜백 서버 객체의 생애주기를 알잘딱으로 관리할 수 있을까
1트: 콜백 서버를 어떻게 관리할 것인가?
로그인을 시작할 때마다 새로 콜백 서버를 시작할지, 기존에 올려둔 객체를 재활용할지부터 고민해야했다.
일단 머릿속에서 생각나는대로 굴러가게만 구현해보자.
void UMyCallbackServer::StartLogin() {
// 콜백 서버 시작
if (CallbackServer == nullptr)
{
CallbackServer = NewObject<UMyCallbackServer>(this);
CallbackServer->AddToRoot();
}
else
{
CallbackServer->StopServer();
CallbackServer = nullptr;
StartLogin();
}
}
기존 객체가 존재하지 않는다면 새로 생성해서 루트에 추가해주고(Root Set에 자식 컴포넌트로 추가해주면, GC collect를 방지할 수 있다.), 이미 존재한다면, 기존 인스턴스를 버리고 재귀호출을 통해 새로운 콜백서버를 생성한다.
얼핏 보면 나쁘지 않지만, 디버깅하다보니 많이 별로였다.
기존 인스턴스를 정리하는 로직과 새로운 인스턴스를 만드는 로직이 재귀 호출 안에 섞여 들어가 있기 때문에, 상태 전이가 명확하게 드러나지 않는다. 함수가 끝나고 다음 함수로 넘어가는 것이 아니라, 실행 중간에 재귀적으로 호출해서 하나의 줄기 속에서 흐름을 이어버리니 디버깅 할 스택이 지저분해지고, 특정 객체가 살아있는지 죽는 중인지도 한 번에 파악하기가 어려웠다.
특히나, 언리얼엔진에서 UObject의 생명주기 문제는 더 복잡하다.
단순히 포인터를 nullptr로 바꾼고 해도, Root 상태, 내부 리스너, 비동기 작업 등 여러 요소가 복합적으로 작용하기 때문에 실제로 GC가 이걸 인식하고 기존 인스턴스를 폐기하기까지는 얼마나 시간이 걸릴 지 알 수 없다. 실제 구현에서 이렇게 진행하면 언제 폐기될지 모르는 GC를 기약없이 기다려야 한다. 한국인은 못 참아.
2트: 그렇다면, 기존 객체를 재활용해보자
void UMyCallbackServer::StartLogin() {
// 콜백 서버 시작
if (CallbackServer == nullptr)
{
CallbackServer = NewObject<UMyCallbackServer>(this);
CallbackServer->AddToRoot();
}
else
{
if (!CallbackServer->RestartServer(CallbackServerPort, this))
{
return;
}
}
}
콜백 서버 인스턴스가 없다면 이전과 마찬가지로 새로 생성하고, 이미 생성된 인스턴스가 존재한다면, `RestartServer()` 메서드를 호출해, 기존 객체를 재활용하는 접근이다.
RestartServer() 메서드는 내부적으로 기존 인스턴스의 state를 null로 초기화하고, listener, route 바인딩을 해제한 다음 새로운 객체를 준비해 반환한다. 만약, 이 과정에서 콜백 서버 자체가 정상적으로 반환되지 않는다면, 리턴값이 없으므로 로그인 프로세스가 중지된다. 객체를 보다 적극적으로 관리한다는 점에서 이전의 코드보다는 발전했다. 하지만 여전히 맹점이 있다.
① 객체는 있는데, 서버가 안 떠있는 경우 👻
콜백 서버 객체 자체는 정상적으로 만들어졌으나, 포트 충돌 등의 사유로 `startServer()`나 `bindListener()`등이 호출되지 못한 경우, 결과적으로 콜백 서버 객체는 존재하지만, 서버는 안 떠 있는 요상한 상황이 발생할 수 있다. 코드 레벨에서 모든 상태를 제대로 구분하지 못한다면, 반쯤 죽어있는 좀비 상태의 콜백서버를 반환하게 될 수 있다. 👻👻
② 기존 객체가 반환된 이후, 정리되기 시작하는 경우 🗑️
이건 1번보다 더 띠용한 상태이다. 분명히 기존에 존재하던 콜백 서버 객체를 발견했고, 이걸 정리해서 부모 함수에게 리턴했는데, 알고보니 해당 객체가 이미 정리되고 있는 경우이다.
결국, 객체를 잘 정리해서 재활용하겠다는 계획은 그 실효성이 꽤나 떨어지는, 복잡한 접근 방식이었음을 알 수 있다. 그래서 나는 재활용을 그만두었다.
3트: 폐기 후 새로운 객체 생성하기
void UMyCallbackServer::StartLogin() {
// 기존 콜백 서버가 있으면 종료하고 삭제
if (CallbackServer != nullptr)
{
CallbackServer->StopServer();
CallbackServer->ConditionalBeginDestroy();
CallbackServer = nullptr;
}
// 새로운 콜백 서버 생성
CallbackServer = NewObject<UWakgamesCallbackServer>(this);
CallbackServer->AddToRoot();
}
결과적으로는 객체를 재시작하는 것보다, 폐기 후 재생성하는 방식이 더 안정적으로 동작했다.
1. 기존 인스턴스가 존재하면 정지한다. (내부적으로 `removeFromRoot()` 호출)
2. 정지한 인스턴스는 즉시 GC가 지체없이 파괴하도록 호출한다.
3. 포인터도 끊어준다.
4. 새로운 객체를 만든다. ✨
이제 코드의 흐름과 상태의 전이가 한눈에 명확하게 보인다. 사실 가장 간단해보이는 구성이지만, 가장 강력한 효과를 가져왔다.무엇보다 '이전의 시도의 잔재'를 최대한 남기지 않고, '로그인하기'버튼을 누를 때마다 새로운 환경에서 시작하도록 보장하겠다는 의도가 그대로 반영됐다.
어줍잖게 자원 절약해보겠다는 생각으로 재활용하는 아이디어를 적용해보려다가 상당한 삽질을 경험해보게 되었다.
그리고 사실 OAuth2.0 인증용 콜백서버를 우다다다 생성하는 사람은 정상적인 유저가 아니므로 크게 고려해야하는 부분도 아니긴 했지만서도... 런타임에서 객체를 어떻게 관리해야할지의 전략을 처음 고민해보게 되는 계기였다.