국비교육(22-23)

82일차(2)/Android App(45) : Interceptor 활용하기

서리/Seori 2023. 2. 7. 00:11

82일차(2)/Android App(45) : Interceptor 활용하기

 

- 로그인 여부에 따라서 응답방식이 달라지는 기능은?

- jsp의 LoginFilter, spring의 Interceptor와 같은 종류

 

- 기존에는 LoginInterceptor 를 만들어서 Webconfig에 등록했다.

- WebMvcConfigurer 인터페이스를 구현하고 @Configuration 어노테이션 붙이기

- 특정 폴더 하위의 모든 요청이 interceptor를 거치게 했다.

 

 

LoginInterceptor

package com.sy.boot07.interceptor;

import java.net.URLEncoder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

//로그인된 사용자인지 검사할 인터셉터
@Component
public class LoginInterceptor implements HandlerInterceptor{

	//Controller 메소드 수행직전에 로그인된 사용자 인지 검증을 해서 
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		//세션 객체의 참조값을 얻어와서 
		HttpSession session=request.getSession();
		String id=(String)session.getAttribute("id");
		//만일 로그인을 하지 않았다면
		if(id == null) {
			//로그인 페이지로 리다일렉트 이동 시키고 false 를 리턴한다.

			//원래 가려던 url 정보 읽어오기
			String url=request.getRequestURI();
			//GET 방식 전송 파라미터를 query 문자열로 읽어오기 ( a=xxx&b=xxx&c=xxx )
			String query=request.getQueryString();
			//특수 문자는 인코딩을 해야한다.
			String encodedUrl=null;
			if(query==null) {//전송 파라미터가 없다면 
				encodedUrl=URLEncoder.encode(url);
			}else {
				// 원래 목적지가 /test/xxx.jsp 라고 가정하면 아래와 같은 형식의 문자열을 만든다.
				// "/test/xxx.jsp?a=xxx&b=xxx ..."
				encodedUrl=URLEncoder.encode(url+"?"+query);
			}

			//3. 로그인을 하지 않았다면  /users/loginform 페이지로 리다일렉트 이동 시킨다. (HttpServletResponse)
			String cPath=request.getContextPath();
			response.sendRedirect(cPath+"/users/loginform?url="+encodedUrl);
			return false;
		}

		//로그인을 했다면 흐름을 이어간다.
		return true;
	}

	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		// TODO Auto-generated method stub

	}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
		// TODO Auto-generated method stub

	}

}

 

- HandlerInterceptor 를 구현해서 만들고 @Component 어노테이션을 붙인다.

 

 

- Bean으로 만들어져서 여기에 이렇게 주입되는 것이다.

- 주입된 interceptor는 registry에서 어떤 경로 요청에 대해서 인터셉터가 동작하게 할 지 설정했다.

 

- 로그인된 아이디가 없다면 리다이렉트 이동을 시키는데,

  안드로이드 앱에 대해서 리다이렉트는 의미가 없다.

- 웹브라우저가 아니므로 리다이렉트 응답에 대한 대비가 되어있지 않으면 동작하지 않는다.

- 따라서 안드로이드 요청에 대해서 대응할 인터셉터를 따로 만들어야 한다.

 


 

MobileLoginInterceptor

package com.sy.boot07.interceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

//Bean이 될 수 있도록 어노테이션 붙이기
@Component
public class MobileLoginInterceptor implements HandlerInterceptor{
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		//세션 객체의 참조값을 얻어와서 
		HttpSession session=request.getSession();
		String id=(String)session.getAttribute("id");
		//만일 로그인을 하지 않았다면
		if(id == null) {
			//인증이 되지 않았다는 에러를 응답한다.
			response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
			return false;
		}

		//로그인을 했다면 흐름을 이어간다.
		return true;
	}
	
	
}

 

- HandlerInterceptor 구현

 

- Component 어노테이션 

- 인증이 되지 않았다면 에러를 응답한다.(401번 에러)

 

- 이 안드로이드의 responseCode=401 인 것과 같다.

 

- 이 코드값이 urlConnection 안에서 getResponseCode 하면 나온다.

 

- 로그인하지 않았다면 에러를 응답하고, login 했다면 개입하지 않는다.

 


 

WebConfig

package com.sy.boot07.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.sy.boot07.interceptor.LoginInterceptor;
import com.sy.boot07.interceptor.MobileLoginInterceptor;

@Configuration
public class WebConfig implements WebMvcConfigurer{
	//로그인 인터셉터 주입 받기
	@Autowired LoginInterceptor loginInterceptor;
	@Autowired MobileLoginInterceptor mLoginInterceptor;

	//인터셉터 동작하도록 등록하기
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		//웹브라우저의 요청에 대해 개입할 인터셉터 등록
		registry.addInterceptor(loginInterceptor)
		.addPathPatterns("/users/*","/gallery/*","/cafe/*","/file/*")
		.excludePathPatterns("/users/signup_form", "/users/signup", "/users/loginform", "/users/login",
				"/gallery/list", "/gallery/detail",
				"/cafe/list","cafe/detail","/cafe/ajax_comment_list",
				"/file/list","/file/download");
		
		//모바일 요청에 대해 개입할 인터셉터 등록
		registry.addInterceptor(mLoginInterceptor)
		.addPathPatterns("/api/gallery/*");
		
		
	}
	// resources 폴더안에 있는 자원을 spring 컨트롤러를 거치지 않고 응답되도록 설정
	// webapp 안에 resources 폴더를 만들어야 한다.
	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
	}
}

 

- WebConfig 에 인터셉터를 추가해줘야 한다.

 

 

- 이런식으로 모바일 요청에 대해서도 interceptor가 개입하도록 할 수 있다.

 

- 이제 로그인했으면 개입하지 않고, 로그인하지 않았으면 401 에러가 응답된다.

 

 


 

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/userInfo"
        android:background="#00ff00"
        android:textSize="30sp"/>
    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/login"
        android:text="로그인"/>
    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/logout"
        android:text="로그아웃"/>
    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/gallery"
        android:text="갤러리 목록보기"/>
</LinearLayout>

 

- 안드로이드에서 http 요청을 받아올 때도, 로그인하지 않은 클라이언트의 임의의 요청을 막아야 할 경우가 있을 수 있다.

 

Android Controller

package com.sy.boot07.api;


import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;


import com.sy.boot07.gallery.dao.GalleryDao;
import com.sy.boot07.gallery.dto.GalleryDto;

@Controller
public class AndroidController {
	
	
	/*
	 * JSON 문자열 
	 * 1. @ResponseBody 어노테이션
     * 2. Map 혹은 List 혹은 Dto 를 리턴하면 자동으로 JSON 문자열로 변환 되어서 응답된다.
	 */
	@RequestMapping("/api/send")
	@ResponseBody
	public Map<String, Object> send(String msg){
		
		System.out.println(msg);
		Map<String, Object> map=new HashMap<>();
		map.put("isSuccess", true);
		map.put("response", "hello client!");
		map.put("num", 1);
		
		return map;
	}
	
	@RequestMapping("/api/list")
	@ResponseBody
	public List<String> list(int pageNum){
		List<String> names=new ArrayList<>();
		names.add("바나나");
		names.add("딸기");
		names.add("복숭아");
		return names;
	}
	
	//로그인 여부를 json으로 응답하는 메소드
	   @RequestMapping("/api/logincheck")
	   @ResponseBody
	   public Map<String, Object> logincheck(HttpSession session){
		  //테스트로 session의 아이디를 출력해보기
	      System.out.println("세션 아이디:"+session.getId());
	      Map<String, Object> map=new HashMap<>();
	      //세션 영역에 id라는 키값으로 저장된 값이 있는지 읽어와 본다.
	      String id=(String)session.getAttribute("id");
	      //만일 로그인을 하지 않았다면
	      if(id == null) {
	         map.put("isLogin", false);
	         System.out.println("로그인중이 아님요");
	      }else {
	         map.put("isLogin", true);
	         map.put("id", id);
	         System.out.println(id+" 로그인중...");
	      }
	      return map;
	   }
	   @RequestMapping("/api/login")
	   @ResponseBody
	   public Map<String, Object> login(String id, String pwd, HttpSession session){
	      //session.setMaxInactiveInterval(60*60*24); //로그인 유지시간
	      System.out.println(id+"|"+pwd);
	      Map<String, Object> map=new HashMap<>();
	      if(id.equals("gura") && pwd.equals("1234")) {
	         map.put("isSuccess", true);
	         map.put("id", id);
	         session.setAttribute("id", id);
	      }else {
	         map.put("isSuccess", false);
	      }
	      return map;
	   }
	   @RequestMapping("/api/logout")
	   @ResponseBody
	   public Map<String, Object> logout(HttpSession session){
	      session.invalidate();
	      Map<String, Object> map=new HashMap<>();
	      map.put("isSuccess", true);
	      return map;
	   }
	   
	   @Autowired GalleryDao dao;
	   
	   @RequestMapping("/api/gallery/list")
	   @ResponseBody
	   public List<GalleryDto> galleryList(){
		   //최신 갤러리 데이터 10개만 가져오기
		   GalleryDto dto=new GalleryDto();
		   dto.setStartRowNum(1);
		   dto.setEndRowNum(10);
		   
		   return dao.getList(dto);
	   }
}

 

- 안드로이드에서 /api/gallery/list를 요청하면 이 dto를 담아서 JSON으로 리턴한다.

- 로그인해야만 정상적으로 응답된다.

 

 

- 해당 페이지에 직접 접속해보면 401 에러가 일어난다.

 로그인하지 않았기 때문에!

 

- 로그인하고 동일한 경로로 들어가면 이렇게 데이터를 보내준다.

 

 


 

새 액티비티- BasicActivity 생성

 

GalleryActivity

package com.example.step18login;

import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.util.Log;
import android.view.View;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;

import com.example.step18login.databinding.ActivityGalleryBinding;
import com.google.android.material.snackbar.Snackbar;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;

public class GalleryActivity extends AppCompatActivity {

    private AppBarConfiguration appBarConfiguration;
    private ActivityGalleryBinding binding;

    SharedPreferences pref;
    String sessionId;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //View 바인딩을 이용해서 화면 구성하기
        binding = ActivityGalleryBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        setSupportActionBar(binding.toolbar);

        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_gallery);
        appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build();
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
        //우하단의 float action 버튼을 눌렀을 때 동작할 리스너 등록하기
        binding.fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                //Snackbar 에 메세지를 길게 띄우기
                Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                        .setAction("Action", null).show();
            }
        });
    }

    @Override
    public boolean onSupportNavigateUp() {
        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_gallery);
        return NavigationUI.navigateUp(navController, appBarConfiguration)
                || super.onSupportNavigateUp();
    }

    @Override
    protected void onStart() {
        super.onStart();
        //SharedPreferences 객체의 참조값 얻어와서 필드에 저장하기
        pref= PreferenceManager.getDefaultSharedPreferences(this);
        //저장된 session id 가 있는지 읽어와 본다.(없다면 기본값은 빈 문자열)
        sessionId=pref.getString("sessionId", "");

        //갤러리 1페이지의 데이터 요청 task 실행하기
        new GalleryListTask().execute(1);
    }

    class GalleryListTask extends AsyncTask<Integer, Void, String>{

        @Override
        protected String doInBackground(Integer... integers) {
            //갤러리 목록 요청 url
            String requestUrl=AppConstants.BASE_URL+"/gallery/list?pageNum="+integers[0];
            //서버가 http 요청에 대해서 응답하는 문자열을 누적할 객체
            StringBuilder builder=new StringBuilder();
            HttpURLConnection conn=null;
            InputStreamReader isr=null;
            BufferedReader br=null;

            try{
                //URL 객체 생성
                URL url=new URL(requestUrl);
                //HttpURLConnection 객체의 참조값 얻어오기
                conn=(HttpURLConnection)url.openConnection();
                if(conn!=null){//연결이 되었다면
                    conn.setConnectTimeout(20000); //응답을 기다리는 최대 대기 시간
                    conn.setRequestMethod("GET");//Default 설정
                    conn.setUseCaches(false);//케쉬 사용 여부
                    //App에 저장된 session id가 있다면 요청할 때 쿠키로 같이 보내기
                    if(!sessionId.equals("")) {
                        //JSESSIONID=xxx 형식의 문자열을 쿠키로 보내기
                        conn.setRequestProperty("Cookie", sessionId);
                    }

                    //응답 코드를 읽어온다.
                    int responseCode=conn.getResponseCode();

                    if(responseCode==200){//정상 응답이라면...
                        //서버가 출력하는 문자열을 읽어오기 위한 객체
                        isr=new InputStreamReader(conn.getInputStream());
                        br=new BufferedReader(isr);
                        //반복문 돌면서 읽어오기
                        while(true){
                            //한줄씩 읽어들인다.
                            String line=br.readLine();
                            //더이상 읽어올 문자열이 없으면 반복문 탈출
                            if(line==null)break;
                            //읽어온 문자열 누적 시키기
                            builder.append(line);
                        }
                    }else if(responseCode== HttpURLConnection.HTTP_UNAUTHORIZED){//로그인이 안된 상태라면
                        //예외를 발생시켜서 try~catch~finally 절이 정상 수행되도록 한다.
                        throw new RuntimeException("로그인이 필요합니다.");
                    }
                }
                //서버가 응답한 쿠키 목록을 읽어온다.
                List<String> cookList=conn.getHeaderFields().get("Set-Cookie");
                //만일 쿠키가 존재한다면
                if(cookList != null){
                    //반복문 돌면서
                    for(String tmp : cookList){
                        //session id 가 들어있는 쿠키를 찾아내서
                        if(tmp.contains("JSESSIONID")){
                            //session id만 추출해서
                            String sessionId=tmp.split(";")[0];
                            //SharedPreferences 를 편집할 수 있는 객체를 활용해서
                            SharedPreferences.Editor editor=pref.edit();
                            //sessionId 라는 키값으로 session id 값을 저장한다.
                            editor.putString("sessionId", sessionId);
                            editor.apply(); //apply() 는 비동기로 저장하기 때문에 실행의 흐름이 잡혀있지 않다.
                            //필드에도 담아둔다.
                            GalleryActivity.this.sessionId=sessionId;
                        }
                    }
                }

            }catch(Exception e){//예외가 발생하면
                Log.e("GalleryListTask", e.getMessage());
            }finally {
                try{
                    if(isr!=null)isr.close();
                    if(br!=null)br.close();
                    if(conn!=null)conn.disconnect();
                }catch(Exception e){}
            }
            //String Builder 객체에 담긴 문자열을 리턴해준다.
            return builder.toString();

        }

        @Override
        protected void onPostExecute(String s) {
            super.onPostExecute(s);
            //응답된 문자열을 토스트 메세지로 띄워보기
            Toast.makeText(GalleryActivity.this, s, Toast.LENGTH_LONG).show();
        }
    }
}

 

activity_gallery.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".GalleryActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/Theme.MyAndroid.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/Theme.MyAndroid.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>

    <include layout="@layout/content_gallery" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_marginEnd="@dimen/fab_margin"
        android:layout_marginBottom="16dp"
        app:srcCompat="@android:drawable/ic_dialog_email" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

 

 

- Basic Activity는 이런 모양을 가지고 있다.

 

- 갤러리 버튼에 GalleryActivity로 이동할 수 있도록 설정 (intent 객체 사용)

 

 

 

- Basic Activity 는 버튼으로 전환 가능한 2개의 프래그먼트로 되어잇고,

 우하단의 이메일 모양의 아이콘을 누르면 하단에 snackbar가 잠시 떴다가 사라진다.

 

 

- GalleryActivity (Basic Activity)의 코드확인

 

- Viewbinding을 사용하고 있다.

- 플로팅 버튼을 누르면 실행할 리스너가 들어있다.

 

 

- 이 스낵바를 누르면 snackbar 안에 있는 리스너가 실행된다.(기본은 null로 등록되지 않은 상태)

 


 

- galleryActivity에서 http 요청 해서 json 문자열 출력해보기

(이미지뷰에 이미지 출력하는건 나중에)

 

- 파라미터 타입은 Integer (페이지번호 전달)

 

- 안에서 doInBackground, onPostExecute 메소드 오버라이드

- 각 메소드의 인자 타입은 AsyncTask의 제너릭 타입과 각각 위와 같은 연관관계를 가진다.

 

- onStart 메소드를 만들고, 1페이지에 해당하는 내용만 불러와 본다.

 

- get방식 파라미터의 pageNum=x 로 나올 수 있도록 코딩하기

 

 

- 200일 경우, 401일 경우로 나누어 if로 분기한다.

 

 

- 각 응답코드는 이렇게 상수로 저장되어 있으므로 상수를 사용하면 더 좋다.

 

 

- 갤러리 액티비티의 sessionId에 넣어주기

 

 

- 그런데 이 중간의 else if 절에서 return 하면 아래의 코드들이 실행되지 않으므로 

 여기서는 예외를 발생시켜 throw로 보내준다.

 

 

- throw로 보낸 내용이 catch 로 들어오고, 최종적으로 Bulilder에 담긴 문자열이 출력될 수 있도록 한다!

 

 

- 로그인하지 않으면 토스트 메시지에 아무것도 응답되지 않는다. (빈 문자열)

 

 

- 로그인하면 이렇게 토스트 메시지로 아까 JSON으로 출력된 문자열이 전달된다.

- 인증된 사용자의 요청에만 선택적으로 응답하도록 했다!

 

 

- 리다일렉트 응답은 301 또는 302이다.

- 이것을 응답하게 하려면 안드로이드에서도 이 리다일렉트 요청에 응답할 대비가 되어있어야 한다.

- 그 대비된 경로로 요청을 해주어야 한다.

 

- 위와 같이 세션(쿠키 기능)을 이용해서 로그인 정보를 받아오고,

 응답되는 쿠키를 확인해서 새로 받은 세션 아이디를 업데이트할 수 있다.