Electron에서 구글 OAuth 구현하기

김가희·6일 전

문제 상황

Electron 앱에 구글 로그인을 연동하면서 처음에는 일반 웹에서 하던 OAuth 흐름을 떠올렸다.

백엔드에는 이미 구글 로그인 API가 구현되어 있었고, 프론트엔드에서는 Orval을 통해 인증 API 코드도 생성되어 있었다. 생성된 API 명세를 보면 구글 로그인과 콜백 엔드포인트는 다음과 같은 구조였다.

GET /api/auth/google
GET /api/auth/google/callback

일반 웹이라면 로그인 버튼을 눌렀을 때 OAuth 페이지로 이동하고, 인증이 끝난 뒤 다시 서비스 화면으로 돌아오면 된다.

하지만 Electron에서는 로그인 화면을 어디에서 열고, 인증 결과를 앱으로 어떻게 돌려받을지 별도로 설계해야 했다. 현재 앱 화면에서 직접 이동할지, 별도 팝업을 띄울지, 시스템 브라우저를 사용할지에 따라 구현 방식과 보안성이 달라지기 때문이다.

결국 이 작업의 핵심은 단순히 구글 로그인 API를 호출하는 것이 아니라, Electron 환경에 맞는 OAuth 흐름을 설계하는 것이었다.



검토 1. 앱 내부 화면에서 OAuth 페이지로 직접 이동하기

가장 먼저 떠올린 방식은 일반 웹처럼 현재 화면을 OAuth 엔드포인트로 이동시키는 것이었다.

const handleGoogleLogin = () => {
  window.location.href = `${import.meta.env.VITE_API_URL}/api/auth/google`;
};

일반 웹에서는 window.location.href로 구글 로그인 페이지에 이동해도 된다. 브라우저 탭이 OAuth 페이지로 갔다가 다시 서비스로 돌아오면 되기 때문이다.

하지만 Electron에서는 현재 Renderer 창이 곧 앱 화면이다. 여기서 window.location.href를 바꾸면 로그인 팝업이 뜨는 것이 아니라, Tickit 앱 화면 자체가 구글 로그인 페이지로 바뀔 수 있다. 그러면 기존 React 화면의 흐름이 끊기고, 로그인 완료 후 다시 앱 상태로 돌아오는 과정도 직접 처리해야 한다.

그래서 현재 앱 화면을 직접 이동시키는 방식은 제외하고, 인증 흐름을 별도 창으로 분리하는 방식을 검토했다.



검토 2. BrowserWindow 팝업으로 OAuth 흐름 분리하기

다음으로 검토한 방식은 Main Process에서 인증 전용 BrowserWindow를 생성하는 구조였다.

이 방식에서는 Renderer에서 구글 로그인 버튼을 누르면 IPC를 통해 Main Process에 auth:google 요청을 보낸다. Main Process는 별도 창을 만들고, 그 창에서 백엔드의 구글 로그인 엔드포인트를 여는 흐름이 된다.

const authWindow = new BrowserWindow({
  width: 500,
  height: 600,
  parent: parentWindow || undefined,
  modal: true,
  show: false,
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
  },
});

authWindow.loadURL(`${apiUrl}/api/auth/google`);
authWindow.once('ready-to-show', () => authWindow.show());

이 방식의 장점은 명확했다.

1. 앱 내부에서 로그인 흐름이 끝난다.
2. 사용자가 외부 브라우저로 이동하지 않아도 된다.
3. Main Process에서 인증 창의 URL이나 로딩 상태를 직접 감시할 수 있다.
4. 인증이 끝나면 창을 닫고 Renderer에 결과를 넘기기 쉽다.

또 백엔드가 인증 성공 후 JSON 응답을 반환하는 구조라면, BrowserWindow 내부 페이지의 내용을 읽어 토큰을 추출하는 방식도 생각할 수 있었다.

const content = await authWindow.webContents.executeJavaScript(
  'document.body.innerText'
);

const data = JSON.parse(content);

하지만 이 방식은 구현 편의성은 높아도, Google OAuth 정책과 장기적인 유지보수 관점에서는 애매했다.

Electron의 BrowserWindow는 앱이 생성하고 제어하는 브라우저 환경이다. Google OAuth에서는 이런 embedded user-agent 방식의 인증을 제한하고 있기 때문에, 구글 로그인에는 사용자의 시스템 기본 브라우저를 사용하는 것이 더 적절하다고 판단했다.

그래서 내부 창에서 로그인 과정을 처리하는 방향은 선택하지 않고, 외부 브라우저에서 인증을 진행한 뒤 앱으로 돌아오는 구조로 바꾸기로 했다.



최종적으로는 시스템 기본 브라우저에서 Google OAuth를 진행하고, 인증 결과만 Deep Link로 Electron 앱에 돌려받는 방식으로 전환했다.

시스템 기본 브라우저 방식은 앱 내부에서 OAuth 화면을 직접 제어하지 않는다. Electron 앱은 브라우저를 열기만 하고, 실제 구글 로그인은 사용자가 평소 사용하는 Chrome, Safari 같은 브라우저에서 진행된다.

Electron App
  → 시스템 기본 브라우저 열기
  → Google OAuth 진행
  → 백엔드 callback 처리
  → tickit:// deep link로 앱 복귀
  → Electron App에서 로그인 상태 반영

이 방식으로 바꾸면 다음 장점이 있다.

1. Google OAuth 정책에 더 적합하다.
2. 사용자는 기존 브라우저에 로그인된 Google 계정을 그대로 사용할 수 있다.
3. 앱이 인증 화면을 직접 제어하지 않기 때문에 보안 신뢰도가 높다.
4. OAuth 결과를 앱 전용 callback으로 명확하게 전달할 수 있다.

물론 단점도 있다.

1. 앱으로 다시 돌아오는 deep link 구조를 설계해야 한다.
2. macOS와 Windows/Linux의 URL 수신 방식이 다르다.
3. 백엔드 callback 응답도 JSON 반환이 아니라 앱 전용 redirect로 바꿔야 한다.
4. 인증 후 브라우저 탭이 남을 수 있어 완료 페이지 UX가 필요하다.

그래도 Google 로그인이라는 특성을 고려하면, 단순히 편한 구현보다 시스템 브라우저 기반 OAuth 흐름이 더 적절하다고 판단했다.


구현 1. shell.openExternal로 시스템 브라우저 열기

먼저 기존 BrowserWindow 생성 로직을 제거하고, Electron의 shell.openExternal을 사용해 시스템 기본 브라우저를 열도록 바꿨다.

import { shell, ipcMain } from 'electron';

ipcMain.handle(IPC_CHANNELS.AUTH_GOOGLE, async () => {
  const apiUrl = process.env.VITE_API_URL || 'http://localhost:3000';

  shell.openExternal(`${apiUrl}/api/auth/google`);

  return new Promise((resolve) => {
    googleAuthResolve = resolve;

    setTimeout(() => {
      if (googleAuthResolve === resolve) {
        googleAuthResolve = null;
        resolve(null);
      }
    }, 60000);
  });
});

이제 구글 로그인 버튼을 누르면 Electron 내부 창이 아니라, 사용자의 기본 브라우저에서 /api/auth/google이 열린다.

여기서 googleAuthResolve를 따로 보관한 이유는 로그인 결과가 즉시 돌아오지 않기 때문이다.

시스템 브라우저 방식에서는 다음 흐름이 비동기로 이어진다.

1. Renderer가 auth:google 호출
2. Main Process가 브라우저 열기
3. 사용자가 브라우저에서 구글 로그인
4. 백엔드가 tickit:// URL로 redirect
5. Electron 앱이 deep link 수신
6. 보관해 둔 resolve를 호출해 Renderer에 결과 반환

즉, IPC 요청을 보낸 시점과 로그인 결과를 받는 시점 사이에 브라우저 인증 과정이 끼어 있다. 그래서 Promise의 resolve를 저장해 두고, deep link가 들어왔을 때 호출하는 구조로 만들었다.

타임아웃도 함께 추가했다.

setTimeout(() => {
  if (googleAuthResolve === resolve) {
    googleAuthResolve = null;
    resolve(null);
  }
}, 60000);

사용자가 브라우저에서 로그인을 끝내지 않거나 중간에 취소하는 경우를 고려해, 일정 시간이 지나면 로그인 실패 또는 취소로 처리하기 위해서다.


구현 2. custom protocol로 앱 복귀 경로 만들기

시스템 브라우저에서 로그인을 진행하면, 인증이 끝난 뒤 다시 Electron 앱으로 돌아오는 경로가 필요하다.

이를 위해 앱 전용 custom protocol을 사용했다.

이번 프로젝트에서는 tickit:// 프로토콜을 등록했다.

if (process.defaultApp) {
  if (process.argv.length >= 2) {
    app.setAsDefaultProtocolClient('tickit', process.execPath, [
      path.resolve(process.argv[1]),
    ]);
  }
} else {
  app.setAsDefaultProtocolClient('tickit');
}

개발 환경에서는 Electron 앱이 일반 실행 파일이 아니라 electron . 형태로 실행될 수 있기 때문에, process.defaultApp 여부에 따라 인자를 다르게 넘겼다.

패키징된 앱에서도 프로토콜이 등록되도록 package.json 설정도 추가했다.

macOS에서는 CFBundleURLTypestickit scheme을 등록했다.

{
  "mac": {
    "extendInfo": {
      "CFBundleURLTypes": [
        {
          "CFBundleURLName": "Tickit",
          "CFBundleURLSchemes": ["tickit"]
        }
      ]
    }
  }
}

Windows에서는 protocols 설정을 추가했다.

{
  "win": {
    "protocols": [
      {
        "name": "tickit",
        "schemes": ["tickit"]
      }
    ]
  }
}

이제 브라우저가 다음과 같은 URL로 이동하면 OS가 Tickit 앱을 다시 열 수 있다.

tickit://auth?access_token=...&refresh_token=...&user=...

여기서 중요한 점은 tickit:// URL을 Google Cloud Console의 redirect URI로 직접 등록하는 것이 아니라는 점이다.

Google OAuth의 redirect URI는 백엔드 callback 주소를 사용한다.

http://localhost:3000/api/auth/google/callback

그리고 백엔드가 이 callback에서 토큰 발급 등 필요한 처리를 마친 뒤, 최종적으로 tickit://auth?...로 다시 redirect한다.


custom protocol을 등록한 뒤에는 Electron 앱이 tickit:// URL을 받아 처리해야 한다.

여기서 OS별 차이가 있었다.

macOS에서는 앱이 실행 중일 때 custom protocol URL이 호출되면 open-url 이벤트가 발생한다.

app.on('open-url', (event, url) => {
  event.preventDefault();
  handleDeepLink(url);
});

반면 Windows와 Linux에서는 custom protocol URL이 앱의 두 번째 인스턴스를 실행하는 방식으로 들어올 수 있다.

그래서 requestSingleInstanceLocksecond-instance 이벤트를 함께 사용했다.

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', (_event, commandLine) => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }

    const url = commandLine.pop();
    if (url) handleDeepLink(url);
  });
}

이렇게 하면 앱이 이미 실행 중인 상태에서 브라우저가 tickit://auth?...를 호출해도, 새 앱 인스턴스를 띄우지 않고 기존 앱 인스턴스가 URL을 받아 처리할 수 있다.

또한 deep link가 들어왔을 때 기존 앱 창을 다시 앞으로 가져오도록 했다.

if (mainWindow) {
  if (mainWindow.isMinimized()) mainWindow.restore();
  mainWindow.focus();
}

로그인이 끝났는데 앱이 뒤에 숨어 있으면 사용자가 로그인 성공 여부를 바로 알기 어렵기 때문이다.


구현 4. 백엔드 callback을 tickit:// redirect로 변경하기

시스템 브라우저 방식으로 바꾸면서 백엔드도 함께 수정해야 했다.

기존 백엔드는 구글 인증이 끝나면 다음과 같은 JSON을 반환했다.

{
  "success": true,
  "data": {
    "message": "구글 로그인에 성공했습니다.",
    "access_token": "...",
    "user": {
      "id": 4,
      "email": "user@example.com",
      "provider": "google"
    }
  }
}

일반 API 응답으로는 이상하지 않지만, 시스템 브라우저 OAuth 흐름에서는 문제가 된다.

브라우저에서 로그인을 완료한 뒤 서버가 JSON만 반환하면, 사용자는 브라우저 화면에서 JSON을 보게 된다. Electron 앱은 그 JSON을 자동으로 받을 수 없다.

따라서 백엔드 callback의 역할을 바꿨다.

기존:
/api/auth/google/callback
  → JSON 응답 반환

변경:
/api/auth/google/callback
  → 토큰 발급
  → tickit://auth?... 로 redirect

NestJS 컨트롤러에서는 @Res()를 사용해 직접 redirect 응답을 보냈다.

async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
  const user = await this.authService.validateOAuthUser({
    email: req.user.email,
    socialId: req.user.socialId,
    provider: 'google',
  });

  const { access_token, refresh_token } = await this.authService.login({
    email: user.email,
    id: user.id,
  });

  const userEntity = new UserEntity(user);
  const userData = encodeURIComponent(JSON.stringify(userEntity));

  const redirectUrl =
    `tickit://auth?access_token=${access_token}` +
    `&refresh_token=${refresh_token}` +
    `&user=${userData}`;

  return res.redirect(redirectUrl);
}

이제 구글 인증이 끝나면 브라우저는 JSON 페이지에 머무르지 않고, tickit://auth?... URL로 이동한다.

그러면 OS가 Tickit 앱을 깨우고, Electron의 Main Process가 해당 URL을 처리한다.


구현 5. 인증 결과를 Auth Store에 반영하기

백엔드가 tickit://auth?...로 리다이렉트하면, Electron 앱은 해당 URL에서 인증 결과를 추출해야 한다.

처음에는 access token과 user 정보만 처리하면 된다고 생각했지만, 백엔드에서 refresh token도 함께 내려주고 있었다.

나중에 access token이 만료되었을 때 자동 갱신 흐름을 만들 수 있도록 refresh token도 함께 저장하기로 했다.

Main Process에서는 deep link URL을 파싱한다.

function handleDeepLink(url: string) {
  if (!url.startsWith('tickit://')) return;

  try {
    const parsedUrl = new URL(url.replace('tickit://', 'http://localhost/'));
    const accessToken = parsedUrl.searchParams.get('access_token');
    const refreshToken = parsedUrl.searchParams.get('refresh_token');
    const userDataStr = parsedUrl.searchParams.get('user');

    if (accessToken && userDataStr && googleAuthResolve) {
      const user = JSON.parse(decodeURIComponent(userDataStr));

      googleAuthResolve({
        access_token: accessToken,
        refresh_token: refreshToken || undefined,
        user,
      });

      googleAuthResolve = null;

      if (mainWindow) {
        mainWindow.focus();
      }
    }
  } catch (e) {
    console.error('Failed to parse deep link data:', e);
  }
}

여기서 tickit:// URL을 바로 new URL()에 넣지 않고, http://localhost/ 형태로 바꿔 파싱했다.

const parsedUrl = new URL(url.replace('tickit://', 'http://localhost/'));

이렇게 하면 searchParams를 사용해 query string을 안정적으로 읽을 수 있다.

IPC 타입에도 refresh token을 추가했다.

'auth:google': {
  args: void;
  returns: {
    access_token: string;
    refresh_token?: string;
    user: UserEntity;
  } | null;
};

Auth Store에도 refreshToken 상태를 추가했다.

interface AuthState {
  isLoggedIn: boolean;
  user: UserEntity | null;
  accessToken: string | null;
  refreshToken: string | null;
}

로그인 성공 시에는 access token과 refresh token을 함께 저장한다.

if (result?.access_token && result?.user) {
  setAuth(result.user, result.access_token, result.refresh_token);
  showToast('구글 로그인에 성공했습니다.', 'success');
  navigate(ROUTES.HOME);
}

이렇게 해서 외부 브라우저에서 시작된 OAuth 결과가 Electron 앱의 로그인 상태로 연결되었다.


UX 처리: 인증 완료 페이지 보여주기

시스템 브라우저 방식으로 바꾼 뒤 로그인은 정상적으로 처리되었다. 하지만 인증이 끝난 브라우저 탭은 그대로 남아 있었다.

Electron 내부 BrowserWindow였다면 앱에서 창을 직접 닫을 수 있었겠지만, 시스템 브라우저는 Electron 앱이 소유한 창이 아니다.
따라서 앱에서 Chrome이나 Safari 탭을 강제로 닫을 수 없다.

이 문제는 기능 오류라기보다 사용자 경험의 문제에 가까웠다.
그래서 백엔드에서 인증 완료 후 사용자에게 간단한 완료 페이지를 보여 주도록 했다.

완료 페이지의 역할은 다음과 같다.

1. 로그인이 성공했다는 피드백 제공
2. 앱으로 돌아가도 된다는 안내 제공
3. window.close()를 시도하되, 실패해도 사용자가 직접 닫을 수 있게 안내
4. Tickit 앱과 비슷한 UI로 브라우저 이탈감을 줄임

브라우저 보안 정책상 window.close()는 사용자가 직접 연 창이 아닌 경우 실패할 수 있다. 따라서 자동으로 닫히는 것을 전제로 하기보다, 완료 안내 페이지를 보여 주는 것이 더 안정적인 방식이라고 판단했다.

완료 페이지는 Tickit 앱의 색상과 로고를 반영해 앱의 연장선처럼 보이도록 구성했다.

<div class="ticket">
  <div class="logo-wrapper">
    <!-- Tickit SVG logo -->
  </div>
  <h1>로그인 성공!</h1>
  <p>인증이 완료되었습니다.<br />창을 닫고 앱으로 돌아가세요.</p>
  <a href="javascript:window.close()" class="btn">창 닫기</a>
</div>

이 페이지는 단순히 예쁘게 보이기 위한 화면이 아니라, 시스템 브라우저 OAuth 흐름에서 남는 마지막 UX 공백을 메우는 역할을 한다.



정리

이번 작업은 단순히 “구글 로그인 기능을 추가했다”가 아니라, Electron 앱에서 Google OAuth 흐름을 어떻게 안전하게 설계할 것인지 고민한 과정이었다.

처음에는 앱 내부 BrowserWindow를 띄워 OAuth를 처리하는 방향을 검토했다. 이 방식은 구현이 간단하고 앱 안에서 흐름이 끝난다는 장점이 있었지만, Google OAuth의 embedded user-agent 제한과 장기적인 보안 관점에서 적절하지 않다고 판단했다.

그래서 최종적으로 시스템 기본 브라우저 + custom protocol deep link 방식으로 구현 방향을 변경했다.

최종 흐름은 다음과 같다.

[Renderer]
Google Login Button Click
        ↓
ipc.invoke('auth:google')
        ↓
[Main Process]
shell.openExternal로 시스템 기본 브라우저 열기
        ↓
[System Browser]
Google OAuth 진행
        ↓
[Backend]
/api/auth/google/callback 처리
        ↓
[Backend]
tickit://auth?access_token=...&refresh_token=...&user=... 로 redirect
        ↓
[Electron Main Process]
custom protocol URL 수신
        ↓
access_token, refresh_token, user 파싱
        ↓
[Renderer]
setAuth(user, accessToken, refreshToken)
navigate(ROUTES.HOME)

이번 작업을 통해 정리한 내용은 다음과 같다.

  1. Electron 내부 BrowserWindow로도 OAuth를 구현할 수는 있지만, Google OAuth 정책 관점에서는 시스템 브라우저 방식이 더 적절하다.
  2. 시스템 브라우저 방식에서는 앱으로 돌아오는 경로가 필요하므로 custom protocol deep link를 설계해야 한다.
  3. macOS와 Windows/Linux는 deep link 수신 방식이 다르기 때문에 open-url, second-instance 처리를 나눠야 한다.
  4. 백엔드 callback이 JSON을 반환하면 앱이 인증 결과를 받을 수 없으므로 tickit:// 같은 앱 전용 URL로 redirect해야 한다.
  5. access token뿐 아니라 refresh token도 함께 전달하고 저장하면 이후 토큰 갱신 흐름을 확장하기 쉽다.
  6. 시스템 브라우저 탭은 앱에서 강제로 닫을 수 없으므로, 인증 완료 안내 페이지를 제공하는 것이 사용자 경험상 더 자연스럽다.

결과적으로 역할은 다음처럼 분리되었다.

  • Renderer: 로그인 버튼 클릭, 로그인 결과 반영, 라우팅
  • Main Process: 시스템 브라우저 호출, deep link 수신, 토큰 파싱
  • Backend: Google OAuth callback 처리, tickit:// redirect 생성
  • Preload: 안전한 IPC 통로 제공

처음에는 단순한 소셜 로그인 연동이라고 생각했지만, 실제로는 Electron의 프로세스 구조, Google OAuth 정책, OS별 deep link 처리, 백엔드 callback 설계까지 함께 맞춰야 하는 작업이었다.

이번 경험을 통해 데스크톱 앱에서 OAuth를 구현할 때는 “로그인이 되는가”뿐 아니라, “인증 화면을 어디에서 열 것인가”, “인증 결과를 앱으로 어떻게 돌려받을 것인가”, “사용자에게 완료 상태를 어떻게 안내할 것인가”까지 함께 설계해야 한다는 점을 배웠다.

0개의 댓글