JS, TS

Flutter, Nextjs 하이브리드앱 환경에서의 세션처리와 효율적인 HTTP 통신방법 (Axios 활용)

Mitchell 2023. 7. 13. 06:37

사용자의 세션처리는 회원가입과 로그인이 있는 프로젝트에서는 언제나 중요한 관문으로 만나게 됩니다. 일반적으로는 서버에서 전달받은 토큰을 쿠키에 저장하고 사용자의 HTTP요청에 응답하는 방법으로 진행하게 됩니다. 하지만 이번에는 앱과 웹뷰의 환경 속에서도 같은 방법으로 세션과 HTTP 통신을 할 수 있는 사례를 공유해보겠습니다.
 
목차

  1. 프로젝트 환경
  2. Flutter, Webview(Nextjs)에서 쿠키로 세션관리하기
  3. 서버사이드와 클라이언트에서 효율적으로 Axios 라이브러리 사용하기

1. 프로젝트 환경

현재 진행중인 프로젝트는 webview중심으로 구현된 앱 프로젝트입니다. Flutter로 앱의 껍데기를 만들고 대부분의 주요 기능은 내부의 Webview를 통해서 제공하는 형태입니다. Webview에 띄워진 프로젝트는 Nextjs(Server-side rendering)로 작성되어 있는데, 일반적인 웹 프로젝트와 다르게 각기 다른 세 곳에서 API server와 통신할 수 있습니다.

API 통신에 대한 간략한 구조도

  • Flutter: Native App쪽에서 직접 통신할 수 있습니다.
  • Webview: Server-side와 Client-side(브라우저)에서 각각 통신할 수 있습니다.
  • Webview쪽에서는 HTTP 통신을 위해 Axios를 사용합니다.
  • API Server는 Expressjs로 작성되었습니다.

2. Flutter, Webview(Nextjs)에서 쿠키로 세션관리하기

2-1. 사용자 인증

- SNS 로그인: SNS로부터 사용자 정보를 받아 API Server에서 인증절차를 거칩니다.
- 자체 로그인: 사용자로부터 계정과 비밀번호를 받아 API Server에서 인증절차를 거칩니다.
- 로그인 성공시 API server로부터 JWT(JSON Web Token)으로 만들어진 Access, Refresh Token을 발급받습니다. 발급된 토큰들은 브라우저의 Cookie에 저장합니다.
 

2-1-1. Token을 Cookie에 저장하기

** 토큰을 별도의 응답메시지로 전달하지 않고 헤더 'Set-Cookie'에 실어담습니다.

/**
 * API SERVER: Expressjs
 */

// SNS 로그인의 경우
res.cookie('access-token', accessToken);
res.cookie('refresh-token', refreshToken);
res.redirect(redirectURI);

// 자체 로그인의 경우
res.cookie('access-token', accessToken);
res.cookie('refresh-token', refreshToken);
res.status(201).json({ message: 'LOGIN_SUCCESS' });

 

2-1-2. Nextjs의 middleware에서 페이지 접근 결정하기

페이지에 도착하기 전에 토큰이 잘 전달되고 있는지 먼저 검사합니다. 전달될 토큰이 없다면 원하는 페이지에 도달하기전에 막도록 합시다. Nextjs에서는 middleware를 설정하여 원하는 페이지에 도달하기 전 하나의 검문소 역할을 해줄 곳을 만들 수 있습니다.

/**
 * NextJS + Typescript
 */

export async function middleware(request: NextRequest) {
    const nextResponse = NextResponse.next()

    /**
    * @로그인페이지로
    * 로그인페이지로 이동하는 것을 "로그아웃"으로 판단함.
    */
    if (request.nextUrl.pathname === '/login') {
        nextResponse.cookies.delete('access-token')
        nextResponse.cookies.delete('refresh-token')

        return nextResponse
    }

    const accessToken = request.cookies.get('access-token')?.value
    const refreshToken = request.cookies.get('refresh-token')?.value

    const hasToken = accessToken || refreshToken
    if (hasToken) return nextResponse
	
    // 토큰이 없으면 로그인 페이지로 튕긴다.
	const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
}

export const config = {
  matcher: [
    '/',
    '/login',
    // 미들웨어를 거쳐야하는 페이지를 등록하세요.
  ]
}

 
 

2-2. 토큰저장하기

저희 서비스는 로그인화면도 Webview에서 진행하고 있기 때문에 2-1 방식으로 진행하면 로그인 성공 시 Webivew의 Cookie에 토큰들이 자동으로 저장되게 됩니다. 그러나 앱을 다시 실행하거나 예기치 문제로 닫히는 경우에 Webview에는 Cookie가 날아가 버리게 됩니다. 그래서 앱 내부에서 직접 Cookie에 담겨 있는 Token들을 저장하고 웹뷰와 동기화하는 프로세스가 필요하겠습니다. 그렇다면 언제 어떻게 하면 좋을까요?
 

2-2-1.  로그인을 완료하고 홈으로 리다이렉션 될 때

로그인이 완료되면 "/"으로 리다이렉션 되면서 Webview Cookie에 Token들이 잘 저장될 것입니다. Flutter 쪽에서는 Webview URL이 변경되는 시점에 쿠키를 읽어 앱의 스토리지 안에 저장하면 되겠습니다.
 

webview_flutter의 JavascriptChannel로 URL의 변경을 감지하기

- URL path의 변경을 감지하고 이벤트를 처리할 수 있도록 채널을 설정하고 핸들러를 작성합니다.

/// FLUTTER의 예시입니다.

Webview(
	// ...
    // 필요한 채널들을 Set으로 전달합니다.
    javascriptChannels: <JavascriptChannel>[
        JavascriptChannel(
          name: 'PathChanged',
          onMessageReceived: (JavascriptMessage message) {
            const currentPath = message.message;
            //
            // 변경된 Path를 사용할 로직들
            //
            
            await _saveTokens();
          },
        ),
      ].toSet()
    // ...
)

 
- Webview (Nextjs)에서 URL 변경사항 메시지 보내기

/**
 * Nextjs + Typescript
 */
 
 // 모든 페이지에서 감지할 수 있도록
 // 루트레벨에 있는 컴포넌트에서 실행하세요.
 const { pathname } = useRouter()
 
 if (typeof window !== 'undefined') {
 	window.PathChanged.postMessage(pathname)
 }

 
이쯤에서 의문이 생길 수 있습니다.  "토큰이 최초 생성되는 '/' 페이지로 이동했는지만 감지하면 되는데 왜 모든 경로에서 위 로직들을 다 태우는거지?" 이에 대한 내용은 Access Token의 재발행과 관련이 있습니다. HTTP통신과정에서 Token을 재발행받는 경우가 있는데, Sever-side에서 Data Fetch가 발생하면 Client-side에서 페이지가 로드될 때 재발행된 토큰이 Cookie에 정상적으로 저장되기 때문입니다. 따라서 '/'이외의 페이지 경로도 감지하여 토큰을 Flutter쪽에 동기화할 필요가 있습니다.
 

FlutterSecureStorage에 Token 저장하기

/// Flutter

// 토큰저장이 필요한 곳에서 호출하기
Future<void> _saveTokens() {
	final cookieString = await _webViewController.runJavascriptReturningResult(
      'document.cookie',
    );
    
    // getCookieValue: 키를 받아서 쿠키 값을 뽑아내는 함수
    // cookieString을 파싱하여 값을 얻는 함수를 구현하세요.
    final accessToken = getCookieValue('access-token');
    final refreshToken = getCookieValue('refresh-token');
    
    final storage = FlutterSecureStorage()
    await storage.write(key: 'access-token', value: accessToken);
    await storage.write(key: 'refresh-token', value: refreshToken);
}

 

2-2-2. 앱을 켜고 웹뷰가 최초로 만들어질 때

webview_flutter에서는 WebViewController의 runJavascript로 Webview에 Cookie를 직접 쓸 수 있습니다. 그래서 웹뷰가 생성되는 시점에 FlutterSecureStorage안에 있는 Token들을 쿠키에 직접 심어주는 방식으로 접근 할 수도 있습니다. 그러나 실제 페이지가 생성될 때까지 쿠키에는 실제로 저장되지 않기 때문에 웹뷰를 최초로 로드하는 시점에 Nextjs의 Server-side에서는 쿠키에서 Token을 찾을 수 없습니다. 
 

TODO: 토큰들을 Sever-side에서 접근가능하게 하면서도 정상적으로 쿠키에 저장하라!

위 임무를 수행하기 위해서 Flutter와 Nextjs의 middleware 협업이 필요하겠습니다.
 
- Flutter에서는 웹뷰를 최초 로드할때 'loadUrl' 메소드를 사용하여 요청헤더에 필요한 값들을 추가하여 WebView를 불러옵니다.

/// Flutter


WebView(
    // ...
    onWebViewCreated: _onWebViewCreated,
    // ...
)

void _onWebViewCreated(WebViewController webViewController) async {
    _controller.complete(webViewController);
    _webViewController = webViewController;
    
    Map<String, String> headers = {};

    headers = {
      'load-context': "initial-create",
      'access-token': await storage.read(key: 'access-token') ?? "",
      'refresh-token': await storage.read(key: 'refresh-token') ?? "",
    };

    _webViewController.loadUrl(initialUrl, headers: headers);
  }

 
- Nextjs의 middleware에서는 'load-context'의 값을 확인하여 최초 로드인 경우 헤더의 토큰들이 쿠키에 저장될 수 있도록 아래와 같이 로직을 추가합니다.

export async function middleware(request: NextRequest) {
    // ...

    /**
     * @최초로드
     * Flutter로부터 토큰 값을 받아옴
     */
	const loadContext = request.headers.get('load-context')
 	if (loadContext === INITIAL_CREATE) {
        nextResponse.headers.set('load-context', INITIAL_CREATE)
        nextResponse.cookies.set('access-token', request.headers.get('access-token') || '')
        nextResponse.cookies.set('refresh-token', request.headers.get('refresh-token') || '')

        return nextResponse
    }
    
    // ...
 }

 


3. 서버사이드와 클라이언트에서 효율적으로 Axios 라이브러리 사용하기

3-1. 사용자 인가처리

로그인을 통해 Token을 발급받아 Cookie에 저장했다면 사용자 인증단계는 완료된 것으로 볼 수 있겠습니다.  이제는 로그인한 사용자에 따라 기능을 제공해줄 수 있도록 인가 단계를 처리해야 하겠습니다.

저희 서비스는 회원가입을 제외한 모든 기능과 페이지에 대한 접근권한은 가입된 사용자로 제한됩니다. 따라서 사용자에게 페이지를 보여주기 전에 발급받은 Access Token으로 사용자 정보를 요청하여 페이지 접근여부를 판단합니다.

 

AuthGuard로 사용자 정보 확인하기

Nextjs의 page에서는 Server side rendering을 위해 getServerSideProps함수를 export한다.
이때 페이지에 필요한 데이터를 Api server로 부터 가져오는데 이 역시 사용자 인가가 필요하게 됩니다.

- 사용자 인가처리 후 사용자 정보와 함께 getServerSideProps 함수를 리턴하는 함수인 AuthGuard를 만듭니다.

/*
 * Nextjs + Typescript
 */
 
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
import { AxiosError } from 'axios'

import { Tokens, User } from '@/src/types'
import { fetchMe } from '@/src/services'
import { INITIAL_CREATE } from '@/middleware'

import { stringToCookieValue } from './common'


type GsspContext = GetServerSidePropsContext & {
  userData: User
  tokens: Tokens
}
type Gssp<T> = (ctx: GsspContext) => Promise<GetServerSidePropsResult<T>>

type GsspResultContext = GetServerSidePropsContext & {
  customContext?: { [key: string]: unknown }
}

type GsspResult<T> = (ctx: GsspResultContext) => Promise<GetServerSidePropsResult<T>>

/**
 * 페이지의 gssp내에서 로그인 체크를 랩핑하는 함수
 * @gssp getServerSideProps의 줄임말
 */
export default function authGuard<Props>(gssp: Gssp<Props>): GsspResult<Props> {
  return async (ctx) => {
    const { req, res } = ctx
    
    let tokens: Tokens = {
      accessToken: req.cookies['access-token'],
      refreshToken: req.cookies['refresh-token']
    }

    // 최초로드일 경우 헤더에서 토큰 뽑아오기
    const loadContext = res.getHeader('load-context')
    if (loadContext === INITIAL_CREATE) {
      const cookies = res.getHeader('Set-Cookie') as string[]
      tokens = {
        accessToken: stringToCookieValue(cookies[0]),
        refreshToken: stringToCookieValue(cookies[1])
      }
    }

    try {
      const { data } = await fetchMe(tokens)

      return gssp({
        ...ctx,
        userData: data,
        tokens
      })
    } 
    catch (error) {
      const errorResult = {
        redirect: {
          destination: `${locale}/login`,
          permanent: false,
        
      return errorResult
    }
  }
 }


- 렌더링 할 페이지에서 사용합니다. AuthGuard에서 넘어오는 UserData도 활용이 가능합니다.

/*
 * Nextjs + Typescript
 * ex) index.tsx
 */
 
//
// 페이지를 렌더하는 Function Component
// userData와 initialFeeds를 props로 받는다.
//

export const getServerSideProps = authGuard<HomePageProps>(
  async ({ locale, userData, tokens }) => {
    try {
      const response = await fetchFeedList({
        userId: userData?.id,
        page: 1,
        tokens
      })

      return {
        props: {
          initialFeeds: response,
          userData: userData,
          ...(await serverSideTranslations(locale ?? 'en', ['common'])),
        },
      }
    } catch (e) {
      return {
        props: {
          userData: userData,
          unreadAlarmCount: 0,
          ...(await serverSideTranslations(locale ?? 'en', ['common'])),
        },
      }
    }
  }
)

 

3-2. Axios 효율적으로 사용하기

이제 Api요청 함수만 잘 짜서 필요한 곳에서 호출하면 끝이겠네~ 라고 생각하셨나요? 안타깝게도 Cookie에서 Token을 가져다 API Server와 요청하는 방법이 Server-side와 Client-side에서 차이가 있습니다.

Client Side
브라우저의 쿠키에서 가져오면 됩니다. Nextjs에서는 universal-cookie 라이브러리를 사용해서 가져오는게 편하겠네요.

Server Side
getServerSideProps에서 context의 req 정보로부터 Cookie에 접근이 가능합니다.

3-2-1. ApiManager Class 만들기

사실 토큰을 가져오는 방식을 제외하면 별도의 함수로 분리할 이유가 없습니다. 각 상황에서 다른 방법으로 토큰을 가져올 수 있도록 class를 만들어 API Server와 통신해보도록 합시다.

import Cookies from 'universal-cookie';
import axios, { AxiosError, AxiosInstance } from 'axios';

import { Tokens } from '../types';

import Token from './token';

export default class ApiManager {
    static BASE_URI = process.env.NEXT_PUBLIC_API_URI_BASE + '/api'

    private _api: AxiosInstance
    private _clientToken: Token
    private _serverToken: Token

    constructor(serverTokens?: Tokens) {
        this._serverToken = new Token()
        if (serverTokens) {
            this._serverToken.accessToken = serverTokens.accessToken
            this._serverToken.refreshToken = serverTokens.refreshToken
        }

        this._clientToken = new Token()
        const cookies = new Cookies()    
        this._clientToken.accessToken = cookies.get('access-token')
        this._clientToken.refreshToken = cookies.get('refresh-token')

        let baseURL = ApiManager.BASE_URI

        if (!serverTokens) {
            if (process.env.NEXT_PUBLIC_RUN_MODE === 'development') {
                baseURL = 'http://localhost:4005/api'
            }
        }
        
        this._api = axios.create({ baseURL })

        this._setRequestInterceptor()
    }

    get api() {
        return this._api
    }

    get token() {
        return this._serverToken.hasToken ? this._serverToken : this._clientToken
    }

    get accessToken() {
        return this.token.accessToken
    }

    get refreshToken() {
        return this.token.refreshToken
    }

    // 요청 인터셉터 설정
    _setRequestInterceptor() {
        this.api.interceptors.request.use(
            req => {
                req.headers['access-token'] = this.accessToken;
        
                return req;
            },
            error => {
                return Promise.reject(error);
            }
        );
    }
}
  • Token도 class로 만들어 관리하기
  • 서버토큰을 넣어서 생성하면 Server side
  • 빈 값으로 생성하면 Client side
  • 어찌됐든 생성된 axios instance에 interceptor에서 header에 Access Token 심어주기

아래와 같이 사용해볼 수 있겠습니다.

// Server Side
const api = new ApiManager({
    accessToken, refreshToken
}).api

const res = await api.get(YOUR_URL)


// Client Side
const api = new ApiManager().api

const res = await api.get(YOUR_URL)


3-2-2. 토큰 재발행하기

Access Token은 유효기간을 짧게하고 Refresh Token으로 주기적으로 재발행하여 사용해야 합니다. Api Server로 부터 Access Token의 만료 신호를 받으면 Refresh Token으로 다시한번 요청하는 로직을 ApiManager의 응답 interceptor에 작성하겠습니다.

import Cookies from 'universal-cookie';
import axios, { AxiosError, AxiosInstance } from 'axios';

import { Tokens } from '../types';

import Token from './token';

export default class ApiManager {
    static BASE_URI = process.env.NEXT_PUBLIC_API_URI_BASE + '/api'

    private _api: AxiosInstance
    private _clientToken: Token
    private _serverToken: Token

    constructor(serverTokens?: Tokens) {
        // ...
        this._setResponseInterceptor()
    }
	
    // ...

    // 응답 인터셉터 설정
    _setResponseInterceptor() {
        this.api.interceptors.response.use(
            res => {
                // Client Side Only - 액세스토큰이 재발행되면 저장한다.
                if (typeof window !== 'undefined') {
                    window.RenewAccessToken.postMessage('renewAccessToken')
                }

                return res;
            },
            error => {
                if (!(error instanceof AxiosError)) {
                    return Promise.reject(error)
                }

                if (error?.response?.status === 401) {
                    // 액세스 토큰이 만료되면 리프레쉬 토큰으로 재요청한다.
                    if (error.response.data.message === 'ACCESS_TOKEN_EXPIRED') {
                        const originalReq = error.config

                        if (originalReq) {
                            originalReq.headers['access-token'] = undefined
                            originalReq.headers['refresh-token'] = this.refreshToken
    
                            return this.api(originalReq)
                        }
                    }
                }

                if (error?.response?.status === 400) {
                    console.error('❌ [ERR] 400 -> ' + error.response.data.message);
                }

                return Promise.reject(error);
            }
        )
    }
}

 

위에서 error쪽 구현에서 Refresh Token으로 재발행된 Access Token은 API server쪽에 응답에서 Cookie에 자동으로 저장됩니다. Flutter에서는 Sever-Side쪽에서 Access Token의 변화가 있으면 PathChanged으로 Token을 다시 가져와서 저장하지만 Client-Side 쪽에서는 변화되었음을 Flutter쪽에 직접 알려주어야 합니다. 위와 같이 'RenewAccessToken'으로 Flutter쪽에 메세지를 전달하고 Flutter에서는 WebView의 쿠키를 읽어 FlutterSecureStorage에 다시 저장하도록 합니다.

/// FLUTTER의 예시입니다.

Webview(
	// ...
    // 필요한 채널들을 Set으로 전달합니다.
    javascriptChannels: <JavascriptChannel>[
        JavascriptChannel(
          name: 'RenewAccessToken',
          onMessageReceived: (JavascriptMessage message) {
            await _saveTokens();
          },
        ),
      ].toSet()
    // ...
)

 

4. 정리

Flutter와 Nextjs를 처음 써보면서 앱과 웹뷰의 서버사이드, 클라이언트 사이드를 넘나드는 측면에서 상당히 어려움을 겪었습니다. 처음에는 되는대로 붙여두었다가 점차 공식문서를 참조하고 여러 레퍼런스를 참조하면서 리팩토링하는 과정에서 위와 같이 어느정도 정리가 되었습니다. 하다보면 더 효율적이고 깔끔한 방법들이 떠올라 개선할 측면이 있을거라고 생각합니다.