JWT 토큰 저장

gang_shik·2022년 4월 6일
1

프로젝트 Fit-In

목록 보기
2/10

이전 글에서 Spring 서버와 통신을 완료하였고 회원가입 & 로그인 로직을 처리함, 이때 로그인을 할 때 Token으로 받아왔는데 이 부분을 어떻게 처리할 지 원리가 무엇인지 알아볼 것임

현재상황

  • 설명에 앞서 원래는 단순하게 서버를 기반으로 진행하는 방식을 생각함

  • URL만 있다면 단순하게 Retrofit을 통한 통신을 통해서 서버에서 설계해준대로 로그인하고 회원가입하고 통신을 하는 1차원적인 생각을 했음

  • 하지만 이게 단순히 가벼운 프로젝트로 시작하고 간단하게 끝나면 문제가 없지만 이것저것 따졌을 때 확장성이 부족함, 그래서 이 부분을 서버에서 인지하고 그 방식이 아닌 토큰 기반으로 도입함

  • 그래서 이를 토큰 기반으로 토큰을 발급받고 서버에 요청을 할 때 토큰을 함께 보내서 토큰을 기반으로 통신을 하는 것

    JWT 방식과 토큰 기반 방식을 자세히 알려면 아래 링크 참조
    토큰 기반 서버
    JWT 방식
    JWT란?
    JWT Spring에 적용한 방식

  • 도식화를 시키면 아래와 같음

  • 서버에 Login 관련 URL의 이메일과 비밀번호를 POST 요청하면 해당 요청값에 따라서 서버에서 회원가입한 데이터를 기반으로 확인하고 Token을 생성해서 응답으로 보내줌

  • 이 방식대로 서버가 설계되었어서 인터페이스 역시 아래와 만든 것임(Login할 Body를 보내고 Response로 Token을 받음)

public interface SignIn {

    @POST("/auth/login")
    Call<TokenDto> getSignIn(@Body AccountLoginDto accountLoginDto);
}

요구사항

  • 먼저 Login 시 토큰을 발급받게 된다면 Access Token과 Refresh Token을 받는데 이 토큰을 기반으로 서버에 API 요청과 응답을 받음, 그러므로 해당 Token 값을 안드로이드에 저장을 해서 필요할 때 사용해야함

  • 이 토큰을 기반으로 통신을 하게 되는데 이 JWT 토큰은 시간이 지나면 만료가 됨 즉, 만료가 된 경우 재발급을 하고 재발급한 토큰을 다시 저장해줘야함

  • 토큰을 기반으로 POST 이외의 통신을 할 때 DTO 형태가 아닌 @Header의 방식으로 Token만을 가지고 통신하는 부분이 필요함


필요한 구현사항

  • 로그인을 하면 응답값으로 Token 값을 주는데 이 Token을 안드로이드 앱 내부의 DB에 저장해야함

  • 앱 내부의 저장하는 방식은 다양하게 생각해볼 수 있음 일단 가장 먼저 생각이 난 부분은 SharedPreferences였고 그 외에 SQLite Database, File? 등 방식이야 여러가지 존재했음

  • 하지만 앱 구조상 통신을 처리하고 쓰는데 있어서 복잡한 저장이 아니기 때문에 SharedPreferences에 key-value 형태로 간단하게 저장을 해두면 통신 때 토큰을 사용하기 용이해 보여서 SharedPreferences를 사용함

  • 그리고 이를 기반으로 통신을 하여서 나머지 재발급 요청 토큰을 바탕으로 GET 요청 등 다른 요청이 정상적으로 처리되는지 체크함


구현

  • SharedPreferences를 전역적으로 사용할 수 있기 때문에 이를 구현함

  • 그리고 Auth가 아닌 Token 값을 기반으로 GET 통신 역시 추가 구현함

  • 만약 토큰을 보냈을 때 만료됐을 수 있는 상황에서 재발급 하는 로직도 구현함

SharedPreferences

  • SharedPreferences는 key-value 형태로 읽고 쓸 수 있고 파일형태로 간단하게 저장되기 때문에 토큰 값만을 잘 저장해두면 이 값을 두고두고 쓰기 때문에 이를 바탕으로 구현을 함

    SharedPreferences 관련 공식문서
    SharedPreferences

  • 그리고 이 SharedPreferences를 전역적으로 많이 쓰기 때문에 이전에 RetrofitBuilder를 만든 것처럼 싱글톤 패턴으로 만듬

  • 단, 여기서 주의할 점이 있는데 SharedPreferences를 불러오는데 있어서 Context를 받아와서 불러오는데 이때 싱글톤으로 만들기 때문에 초기화 함수를 통해서 SharedPreferences를 사용하는 곳의 Context를 불러와 인스턴스를 초기화 해주고 써줘야함

  • 아래와 같이 싱글톤으로 만듬 Preferences 이름과 각각 Access Token과 Refresh Token에 대한 설정과 불러오는 함수로 넘겨받은 값을 미리 정한 Key값을 기준으로 읽고 쓰기를 실행함

  • 그리고 로그아웃을 하는 경우 토큰을 없애므로 SharedPreferences에서 없앰

  • 이 과정은 SharedPreferences.Editor를 만들어서 처리하는 것임(이는 edit() 호출하여 진행)

public class Utils {
    private static final String PREFS = "prefs";
    private static final String Access_Token = "Access_Token";
    private static final String Refresh_Token = "Refresh_Token";
    private Context mContext;
    private static SharedPreferences prefs;
    private static SharedPreferences.Editor prefsEditor;
    private static Utils instance;

    public static synchronized Utils init(Context context) {
        if(instance == null)
            instance = new Utils(context);
        return instance;
    }

    private Utils(Context context) {
        mContext = context;
        prefs = mContext.getSharedPreferences(PREFS,Context.MODE_PRIVATE);
        prefsEditor = prefs.edit();
    }

    public static void setAccessToken(String value) {
        prefsEditor.putString(Access_Token, value).commit();
    }

    public static String getAccessToken(String defValue) {
        return prefs.getString(Access_Token,defValue);
    }

    public static void setRefreshToken(String value) {
        prefsEditor.putString(Refresh_Token, value).commit();
    }

    public static String getRefreshToken(String defValue) {
        return prefs.getString(Refresh_Token,defValue);
    }

    public static void clearToken() {
        prefsEditor.clear().apply();
    }
}

로그인

  • 이전 글에서 회원가입 부분만 체크하고 넘어갔는데 로그인도 동일함

  • 그에 맞게 DTO, Retrofit 인스턴스, 인터페이스를 만들었기 때문에 동일한 과정을 바탕으로 불러와서 처리하면 됨

  • 먼저 Retrofit 인스턴스를 만든 후 로그인에 해당하는 Interface를 구현함

        SignIn signIn = RetrofitBuilder.getRetrofit().create(SignIn.class);

  • 그리고 로그인 화면에서 입력받은 이메일과 비밀번호를 바탕으로 DTO를 만들어서 Call 객체에 signIn 인터페이스에 getSignIn을 등록함

  • 이 부분은 Login하는 이메일과 비밀번호를 DTO로 보내면 응답값으로 Token을 받음

public interface SignIn {

    @POST("/auth/login")
    Call<TokenDto> getSignIn(@Body AccountLoginDto accountLoginDto);
}
  • 그리고 이를 비동기 처리하면 아래와 같이 응답값으로 토큰 값을 받음 Callback 리스너를 받아서 TokenDTO로 받은 Access Token과 RefreshToken에 대해서 SharedPreferences에 저장함
binding.btnLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                email = binding.etEmail.getText().toString();
                password = binding.etPassword.getText().toString();
                AccountLoginDto account = new AccountLoginDto(email, password);
                
                Call<TokenDto> call = signIn.getSignIn(account);
                call.enqueue(new Callback<TokenDto>() {
                    @Override
                    public void onResponse(Call<TokenDto> call, Response<TokenDto> response) {
                        if (!response.isSuccessful()) {
                            Log.e("연결이 비정상적 : ", "error code : " + response.code());
                        } else {
                            Utils.setAccessToken(response.body().getAccessToken());
                            Utils.setRefreshToken(response.body().getRefreshToken());
                            Log.e("Login", "at : " + Utils.getAccessToken("nein"));
                            Log.e("Login", "rt : " + Utils.getRefreshToken("none"));

                        }
                    }

                    @Override
                    public void onFailure(Call<TokenDto> call, Throwable t) {

                    }
                });

                Intent intentHome = new Intent(getApplicationContext(), HomeActivity.class);
                startActivity(intentHome);
                finish();
            }
        });
  • 여기서 앞서 SharedPreferences를 전역적으로 쓰기 위해서 만든 Utils를 활용함, 이 때 set은 SharedPreferences의 쓰기를 get은 읽기를 하는 것인데 Utils에서 정의한대로 그대로 쓰면 되고 그 전에 LoginActivity에서 쓰는 것이므로 해당 부분에 대해서 init 작업을 해줘야함
Utils.init(getApplicationContext());
  • 그러면 앞서 싱글톤으로 만든 Utils에서 LoginActivity에 Context를 넘겨받아 인스턴스가 생성되고 해당 부분에서 Access Token과 Refresh Token을 set하고 get을 하는 것임

또 다른 API 통신

지금까지 상황을 정리해보면 회원가입 & 로그인을 진행하기 위한 화면을 만들어 거기서 입력받은 값에 대해서 서버와 통신을 하여서 정상적으로 DB에 저장을 하고 로그인 요청시 응답으로 받은 토큰 값에 대해서 SharedPreferences에 저장해뒀다

  • 우선 어떻게 보면 Retrofit 통신에 있어서 가장 기본적인 방식을 처리를 한 것이라고 볼 수 있음

  • 이제 이 저장한 Token 값을 바탕으로 서버가 토큰 기반 통신을 하기 때문에 Token을 붙여서 REST API 통신을 하는것과 Token 자체의 만료기간이 있기 때문에 이 부분에 대해서 만료처리에 대한 부분을 고민해봐야함

  • 아직 구체적으로 또 다른 서버 통신에 대한 세부 스펙이 정해지지 않았지만 토큰을 기반으로 Get 통신하는 것은 아래와 같이 Controller가 정해짐

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class AccountController {

    private final AccountService accountService;

    @GetMapping("/me")
    public ResponseEntity<AccountResponseDto> getMyAccountInfo(){
        return ResponseEntity.ok(accountService.getMyInfo());
    }

    @GetMapping("/{email}")
    public ResponseEntity<AccountResponseDto> getAccountInfo(@PathVariable String email){
        return ResponseEntity.ok(accountService.getAccountInfo(email));
    }

}
  • member/me URL에서 토큰 값을 보낸다면 해당 값에 맞는 이메일을 응답값으로 보냄

  • 여기서 좀 차이점이 있다면 이전 로그인 & 회원가입에 대한 Controller에서는 Body값이 있었지만 해당 API에는 없음, 이는 accountService로 들어가면 내부적으로 토큰을 받으면 그 값을 기준으로 Repository에서 해당하는 유저정보를 꺼내와서 응답값으로 보내기 때문임

  • 사실 이 내부적인 서버는 구체적으로까지 알 필요 없어도 일단 중요한 것은 Token 값을 HTTP 통신시 Header에 붙여서 보내면 응답으로 그에 맞는 이메일을 보낸다는 것

  • 즉 아래처럼 HTTP Header에 Authroization으로 Bearer + Access Token으로 보내는 것임(아래 예시는 과정을 보여주기 위한 것이지 실제 401, 200등 response code는 무시해도 됨)

  • 이 Bearer는 JWT 인증방식임을 나타내기 위한 것임

  • 일단 위에서의 설명은 서버단에서 어떤식으로 구성됐는지의 내용이고 안드로이드 단에서 구성을 알아보면 됨

  • 근데 안드로이드 단에서도 통신에 있어서는 큰 차이가 없음 똑같이 응답에 있는 DTO를 만들어 응답값을 받고 Interface를 만들며 Retrofit 인스턴스를 만들어서 활용하면 됨

  • 조금의 차이가 있다면 위에서 말한대로 @HeaderAuthorization으로 붙여하는 것 빼고는 Interface 구성도 유사함

public interface GetAccount {

    @GET("/member/me")
    Call<AccountResponseDto> getEmail(@Header("Authorization") String accessToken);
}
  • 테스트를 위해서 아래와 같이 화면을 구성함, 여기서 이메일 불러오기를 누르면 위의 API 통신을 하고 Token을 헤더에 붙여서 응답으로 해당 Token에 맞는 이메일을 불러와서 나타냄

  • 앞서 말했듯이 통신을 위한 진행과정은 동일하게 진행됨 RetrofitBuilder로 인스턴스를 생성해 GetAccount를 구현하고 Call에 등록하여서 비동기로 응답을 받음
        GetAccount getAccount = RetrofitBuilder.getRetrofit().create(GetAccount.class);
  • 이때 SharedPreferences 역시 초기화해서 context를 넘겨서 받아줘야함, 로그인을 했다는 가정하에 SharedPreferences에 있는 Token 값을 활용할 것이므로
Utils.init(getApplicationContext());
  • 그러면 아래와 같이 call을 등록하는데 SharedPreferences에서 값을 가져오고 통신을 함 그러면 결과값으로 이메일을 받아오고 위의 이메일 주소에 해당하는 이메일 주소가 보여짐
binding.btnGetinfo.setOnClickListener(new View.OnClickListener(){
            @Override
            public void onClick(View view) {
                Call<AccountResponseDto> call = getAccount.getEmail("Bearer "+Utils.getAccessToken("nein"));
                call.enqueue(new Callback<AccountResponseDto>() {
                    @Override
                    public void onResponse(Call<AccountResponseDto> call, Response<AccountResponseDto> response) {
                        if (!response.isSuccessful()) {
                            Log.e("연결이 비정상적 : ", "error code : " + response.code());
                            Log.e("Not renewal", "At:"+Utils.getAccessToken("nein"));
                        } else {
                            Log.e("At", "At:"+Utils.getAccessToken("nein"));
                            binding.tvEmail.setText(response.body().getEmail());
                        }
                    }

                    @Override
                    public void onFailure(Call<AccountResponseDto> call, Throwable t) {

                    }
                });
            }
        });

Interceptor & 401 Error

  • 일단 위의 과정까지 지나면 별 문제가 없어보이지만 한가지 간과한 부분이 있음

  • 지금 테스트 상황에서야 그 시간 동안 에러가 날 확률이 없지만 토큰은 만료기간이 존재하기 때문에 이 부분에서 만료가 된다면 단순히 위의 코드로 대응을 하기가 어려움

  • 굳이 저 상황에서 한다면 response.code가 각각 에러가 나올 때 처리를 하는데 그렇게 하면 이미 늦는게 에러가 발생하고 다시 눌렀을 때 처리한다는 것이 사용자 입장에서는 그런 시나리오가 없고 불편함

  • 그래서 그런 에러가 나오기 전 사전적으로 처리하는데 그 부분을 Interceptor가 해 줄 수 있음

Interceptor란

서버쪽에서도 추가할 수 있고 클라이언트단에서도 추가할 수 있는데 현재 프로젝트에선 클라이언트단에 추가함, 이 역할을 직관적으로 이해하기 위해서 사진을 보면

즉 Interceptor를 통해서 서버에게 데이터 전송 및 수신받을 때 중간에 매개체가 되어 어떠한 처리도 가능하게 해 줌

  • 위와 같이 Interceptor가 중간에 통신을 가로챌 수 있는데 그럼 이를 통해서 Error에 대해서 처리를 해 볼 것임

  • 주요하게 고려할 부분은 지금 토큰이 만료됐다면 이 만료된 토큰으로 계속해서 API 통신을 하는데 이 만료된 토큰을 만료된 시점에서 재발급을 하고 SharedPreferences를 다시 갱신시켜줘야함

  • 하지만 여기서 Interceptor에서 Context를 불러올 수가 없음, 이건 특정 Activity나 어떤 상황이 아니라 Interceptor 그 자체를 구현해서 쓰는 것이기 때문에

  • 그래서 이 앱의 Context를 직접 불러오는 방식으로 인스턴스를 가져와서 SharedPreferences를 불러옴(Applicaiton을 선언하고 Mainfest에 이름을 불러서 해줘야함)

public class MyApp extends Application {
    private static MyApp instance;

    public static MyApp getInstance() {
        return instance;
    }

    public static Context getContext(){
        return instance;
    }

    @Override
    public void onCreate() {
        instance = this;
        super.onCreate();
    }

}
  • 이렇게 하는데 있어서 문제가 발생할 수 있음, 메모리 누수 문제나 Context를 불러오는데 문제가 생길 수 있음, 그래도 Context를 불러와서 처리하기 때문에 Interceptor에서 처리할 게 가볍기 때문에 가능함

  • 그리고 Interceptor를 구현하면 아래와 같음

  • 이 부분의 간략하게 설명한다면 SharedPreferences에서 Token 값을 가져오고 해당 Request와 Response를 Interceptor를 통해서 통신을 함, 그리고 401 에러가 발생할 경우 Token을 재발급하는 Interface를 활용해서 SharedPreferences에 Token 값을 갱신해서 저장함, 그리고 정상 토큰으로 처리된 결과를 resoponse로 return을 함

  • 그래서 return을 보면 정상적으로 token을 받으면 기존의 HTTP 통신에 Header를 재발급 받은 토큰을 기준으로 다시 통신을 해서 응답을 return하면 됨

  • ReIssue의 경우 동일하게 인스턴스를 만들어서 Dto를 보내서 통신을 함(여기선 동기처리를 함)

public interface ReIssue {

    @POST("/auth/reissue")
    Call<TokenDto> getReIssue(@Body TokenRequestDto tokenRequestDto);
}
public class AuthInterceptor implements Interceptor {


    @NonNull
    @Override
    public Response intercept(@NonNull Chain chain) throws IOException {
        ReIssue reIssue = RetrofitBuilder.getRetrofit().create(ReIssue.class);
        Utils.init(MyApp.getContext());
        String at = Utils.getAccessToken("none");
        String rt = Utils.getRefreshToken("nein");

        Request original = chain.request().newBuilder().header("Authorization", "Bearer " + at).build();
        Response response = chain.proceed(original);

        if (response.code() == 401) {

                TokenRequestDto tokenRequestDto = new TokenRequestDto(at, rt);
                retrofit2.Response<TokenDto> token = reIssue.getReIssue(tokenRequestDto).execute();
                if (token.isSuccessful()) {
                    Utils.setAccessToken(token.body().getAccessToken());
                    Utils.setRefreshToken(token.body().getRefreshToken());
                    Log.e("Interceptor", "At:" + Utils.getAccessToken("nein"));
                    return chain.proceed(original.newBuilder().header("Authorization", "Bearer " + token.body().getAccessToken()).build());
                }

                return chain.proceed(original.newBuilder().header("Authorization", "Bearer " + Utils.getAccessToken("nein")).build());

        }
        Log.e("Code What?", String.valueOf(response.code()));
        return response;
    }
}
  • 그리고 이 부분에서 다시 RetrofitBuilder를 볼 수 있음, 이 Interceptor는 Retrofit 인스턴스를 만들 때 Interceptor로써 달 수 있음 즉, 이말은 Retrofit을 통해서 통신을 할 때 위의 Interceptor에서처럼 만약 token을 기반으로 통신을 하는 상황에서 401 에러가 발생시 재발급을 처리해주는 것임
public class RetrofitBuilder {

    // 기본 Retrofit 세팅 기준 URL을 가지고
    public static Retrofit getRetrofit() {
        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        OkHttpClient client = new OkHttpClient.Builder()
                .addInterceptor(interceptor)
                .addInterceptor(new AuthInterceptor())
                .build();

        return new Retrofit.Builder()
                .baseUrl("http://10.0.2.2:8080")
                .client(client)
                .addConverterFactory(GsonConverterFactory.create())
                .build();
    }

}
  • 이렇게 되면 이메일 불러오고 다른 API 통신에 대해서 원 로직은 그대로 있지만 Interceptor를 통해서 에러를 처리할 수 있었음, 이는 다른 에러가 있을 경우 유사하게 처리할 수 있음

정리 & 아쉬운 점

  • 위에서 알아본 것은 이 Fit-In 프로젝트에서 실제로 구현된 API는 아님, 하지만 현재 REST API 골자가 JWT 방식으로 설계가 되어있고 이를 바탕으로 다른 API 통신을 하기 때문에 이에 대해서 Token 기반에 다른 API 통신과 만료기간에 따른 Interceptor를 구현한 것임

  • 그래서 지금까지 알아본 통신 사례와 더불어서 위와 같은 네트워크 처리는 유사하게 하는 것을 인지하고 앞으로 설계되는 서버 통신에 연결지어서 처리하면 됨

  • 아래의 사항을 추후 업데이트 할 때 고려해봐야 할 것 같음

1. Interceptor에서 Context를 처리하는 부분에 있어서 개선의 여지가 필요해보임(전반적인 로직이 바뀔 수 있음)

2. Interceptor에서 동기처리로 했는데 이 부분은 추후 성능 문제나 스레드상의 문제가 추가적으로 발생할 수 있기 때문에 비동기처리로 바꿔야 할 수 있음

3. Kotlin으로의 코드 개선도 개선이지만 구조적인 아키텍처 부분에 있어서 더 개선을 할 필요가 있어보임 지금은 구현에 포커스를 맞췄지만 그래도 나름의 요구사항과 명세서가 정확하기 때문에 이를 기반으로 아키텍처나 안드로이드 디자인 패턴 MVVM 등으로 개선을 할 필요가 있어보임


참고링크

Android에 JWT 통신에 대해서 참고한 골자 및 내용
https://medium.com/android-news/token-authorization-with-retrofit-android-oauth-2-0-747995c79720
https://velog.io/@jinstonlee/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90%EC%84%9C-JWT-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
https://github.com/mahmudinm/client-android-laravel-login-jwt

Interceptor에 대한 내용과 적용에 참고한 골자
https://blog.mindorks.com/okhttp-interceptor-making-the-most-of-it
https://modelmaker.tistory.com/entry/Android-Okhttp-Interceptor%EB%A1%9C-%EC%9B%90%ED%95%98%EB%8A%94-%EC%9D%91%EB%8B%B5%EC%9C%BC%EB%A1%9C-%EB%B3%80%ED%98%95%ED%95%98%EA%B8%B0%EC%99%80-Interceptor-Test?category=758157?category=758157
https://stackoverflow.com/questions/32546024/how-to-access-context-in-a-interceptor
https://medium.com/@emmanuelguther/android-refresh-token-with-multiple-calls-with-retrofit-babe5d1023a1
https://stackoverflow.com/questions/60539958/how-to-check-token-expiration-at-interceptors-android-retrofit
http://sangsoonam.github.io/2019/03/06/okhttp-how-to-refresh-access-token-efficiently.html

profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글