[    http://partnerjun.tistory.com/51 님 블로그 내용을 포스팅한 것을 미리 밝힙니다. ]


1. Chrome 개발자도구 Network 탭

크롬에서 F12키나 Ctrl+Shift+I 혹은 메뉴의 '도구 더 보기'에서 열 수 있는 개발자도구는 아주 강력하다. HTML dom 탐색은 물론 javascript나 css 소스를 탐색하고 수정할 수 있을뿐 아니라 수정해 곧바로 적용해 볼 수 있다. 이번 포스트에서 주로 사용하는 Network탭에서는 실시간으로 Request / Response 정보를 확인 수 있다.



각 요청이 시작되기 전/후를 스크린샷으로 남기는 기능과 원하는 요청만 표시하는 필터같은 유용한 기능도 있다. 다양한 기능을 한번씩 사용해 보면 많은 도움이 된다.




2. Jsoup로 네이버 검색어 자동완성 목록 얻어오기

네이버 검색창에 단어를 입력했을 때 나오는 검색어 자동완성 목록을 Jsoup로 얻어보자.


이거.



먼저 크롬의 개발자 도구를 열어 두고 네이버 검색창에 단어를 입력해 보자. 키를 누를 때마다 Request/Response가 감지된다.





검색창에 입력할 때마다 특정 URL로 get Request가 있다는 사실을 알 수 있다. Response 탭을 이용해 Response를 확인해 보자.




뭔가 이상하다. 이건 무슨 코드일까?




다시 Request의 헤더를 보자. _callback 파라미터와 Response의 첫 부분이 같다는 사실을 알 수 있다. 


 


또한 window라는 Javascript Object와 __jindo_callback... 형식이 함수와 유사하다는 점을 통해


 클라이언트에서 _callback 파라미터로 함수의 이름을 전달하고, 

 서버에서 _callback 파라미터로 전달된 함수의 파라미터로 '결과' Json을 적어 반환해 

 클라이언트에서 결과 '문자열'을 실행하거나 정의하는 형식이라고 추측할 수 있다. 


그림으로 표현하면 아래와 같다.



그야말로 막연한 추측이다. 하지만 _callback 파라미터를 조정해 볼 필요는 있다. 한번 시도해 보자.



Chrome 확장프로그램 Advanced REST client로 테스트한 결과.

_callback 파라미터를 공백으로 요청하자 Json 형태로 결과를 얻을 수 있었다.



개인적인 경험상, 개발의 편의성 때문인지 HTML 코드를 그대로 반환하는 사이트가 가장 많고, 그 다음으로 위와 같이 Json과 다른 형식의 코드가 섞인 경우가 많았다. Json 포맷으로 Response가 오는 정직한 경우는 별로 없으니 얻어낸 문자열을 다시 가공하거나 위의 경우처럼 파라미터를 조정해 볼 필요가 있다.



아무튼, 위에서 알아낸 URL과 Request 헤더들을 이용해 Jsoup로 네이버의 검색어 자동완성 목록을 얻어내 보자.


String q = "스칼라"; // 검색어

Document doc = Jsoup.connect("https://ac.search.naver.com/nx/ac")
.header("origin", "http://www.naver.com")
.header("referer", "https://www.naver.com/")
.header("accept-encoding", "gzip, deflate, sdch, br")
.data("con", "1")
.data("_callback", "") // _callback 파라미터를 비우면 JSON이 리턴된다!
.data("rev", "4")
.data("r_enc", "UTF-8")
.data("q", q) // 임의로 몇개의 파라미터와 헤더를 생략했다.
.data("st", "100") // 각 파라미터가 무엇을 뜻하는지를 확인해 적절하게 사용하는 것도 좋지만
.data("q_enc", "UTF-8") // 비정상적인 요청으로 감지해 아이디나 아이피가 밴 될 우려도 있으므로
.data("r_format", "json") // 특별한 이유가 없다면 모두 포함하는 것이 좋다.
.data("t_koreng", "1")
.data("ans", "2")
.data("run", "2")
.ignoreContentType(true) // HTML Document가 아니므로 Response의 컨텐트 타입을 무시한다.
.get();

List<String> result = new ArrayList<>();

// org.json 라이브러리를 사용해 결과를 파싱한다.
JSONObject jsonObject = new JSONObject(doc.text());

JSONArray items = (JSONArray) ((JSONArray) jsonObject.get("items")).get(0);
for(int i=0; i<items.length(); i++) {
String item = (String) (((JSONArray) items.get(i)).get(0));
result.add(item);
}

// 얻어낸 추천 검색어 목록.
// 테스트 프로젝트의 자바 버전이 낮아 for문을 사용했다.
for(String item : result) {
System.out.println(item);
}
/*
스칼라티움 강남
스칼라티움
구글스칼라
스칼라
강남 스칼라티움
첼로 스칼라티 105
스칼라티움 상암
수원 스칼라티움
상암 스칼라티움
첼로 스칼라티
구리 스칼라티움
스칼라 동시성 프로그래밍
스칼라 월드 북스 3
스칼라 월드 북스 4
스칼라 월드 북스 5
*/


원하는 정보를 얻어냈다.(웨딩홀 이름이 가장 위라니 조금 슬프다)




이 예제에는 없었지만 XMLHttpRequest 객체를 사용하는 Request에는 'X-Requested-With' 헤더 값으로 'XMLHttpRequest'가 전송되기도 한다. 다시한번 말하지만 사이트마다 다르고 비정상적인 요청으로 간주될 수 있으니 브라우저에서 직접 헤더를 확인해 보고 Jsoup의 헤더에 똑같이 작성하는 것이 좋다.

[ http://partnerjun.tistory.com/43 님의 티스토리 글을 포스팅한 내용임을 미리 밝힙니다. ]


이 포스트에서는 로그인이 필요한 사이트와 Request Header를 검사하는 사이트를 파싱하는 과정을 적어둔다.



0. 웹 사이트 로그인

먼저 웹 사이트에 로그인에 대해 다시 생각해 볼 필요가 있다. 최근 웹 사이트에서 사용되는 로그인 방법은 크게 두 가지로 볼 수 있다. 첫 번째는 세션을 이용한 방법이고, 두 번째는 Restful API에 주로 사용되는 토큰 인증이다. 발급 받은 토큰을 이용하는 방법은 이전 포스트에서 원하는 값을 Jsoup의 Document를 파싱해 얻어낸 것처럼, 간단하게 얻어낼 수 있다. 물론 토큰이 HTML요소가 아니라 Script 요소로 있는 경우도 많지만 정규식이나 replace, split 같은 메소드를 이용하면 별 어려움이 없다.


다시 첫 번째 세션 로그인으로 돌아가면, 세션은 결국 쿠키라는 사실을 기억해야 한다. 상태를 유지하지 않는 HTTP 프로토콜의 특성 상 사이트에 로그인하는데 성공하면 서버는 클라이언트에게 세션ID를 발급해주고 

ID/PW는 Request에, 세션ID는 쿠키에 담겨 있다

 

클라이언트는 서버로부터 받은 세션ID를 다음 Request부터 쿠키에 포함해 전송하게 된다. 서버는 클라이언트가 전송한 쿠키에서 얻어낸 세션ID를 이용해 이 유저가 '로그인 한' 유저인지 여부를 확인할 수 있게 된다.


Response에도 당연히 쿠키가 포함되어 있다.

뭐 결국 간단히 말하자면 세션으로 로그인을 체크하는 사이트라면 로그인하고 얻은 쿠키를 다음 Request부터 계속 사용하면 된다는 말이다.


 

0. 사이트의 CSRF Token, Request Header

대부분의 유명한(사용자가 많은) 사이트에서는 비정상적인 접근을 막기 위해 여러가지 방법을 사용한다. 그 중 신경써야 할 것은 CSRF 토큰과 Request Header이다. 

 

CSRF 토큰은 로그인 시도 전에 한 가지 단계를 더 거치면 된다. 로그인을 처리하는 URL에 바로 요청하는 것이 아니라 '로그인 페이지' 에 접근해서 토큰을 얻어낸 후로그인 처리 URL에 토큰을 포함해 요청해야 한다. 티스토리를 예로 들어보자.

 

파란색으로 표시된 '눈에 보이는' 직접 입력하는 fieldset 외에도 

특수한 키가 적혀있는 파라미터 두 개가 있다.

 

스크린샷에서 볼 수 있는 두 가지 파라미터 "ofp"와 "nfp"처럼 로그인 페이지에 접속해야 얻을 수 있는 값이 있다. 때문에 먼저 '로그인 페이지'에 접근해 저런 값들을 얻어낸 후, '로그인 처리 URL'에 보내는 데이터에 그 값들을 포함하면 된다.

 

Request Header는 HTTP 표준에 맞게 전송하는 것이 원칙이다. 정상적인 브라우저라면 따로 신경 쓸 필요가 없지만 Jsoup를 통한 접속에서는 신경써야 한다. 몇몇 사이트에서는 Request Header를 철저하게 검사해 접근을 막거나 아이디를 밴하기도 한다. 

Header값들을 얻는 가장 쉬운 방법은 브라우저로 직접 로그인 해 보고 헤더 값들을 모두 복사해 그대로 사용하는 것이다.


 

티스토리에 로그인할때 전송된 Request header

(Chrome 확장 프로그램 HTTP Headers를 이용함)

 

다른 값들은 그냥 넣는다고 해도 User-Agent만큼은 조금 신경 쓸 필요가 있다. 사용자의 브라우저를 확인하는 값이기 때문이다. 이 값을 모바일 브라우저로 변환한다면 모바일 페이지를 따로 사용하는 사이트에서는 모바일 페이지로 리다이렉트된다. 만약 얻어내고자 하는 값이 모바일 화면의 값이라면 적절한 User-Agent를 하나 구해 사용하자.(기종명 User Agent로 검색하면 다 나온다)




그럼 이제 티스토리에 로그인하고 블로그 관리 페이지에서 내 블로그 목록을 얻어내는 코드를 만들어 보자.


select의 option값


1. 티스토리 로그인 페이지에 접속해 토큰 얻어내기

위에서 본 것처럼 티스토리에서는 로그인에 두 가지 토큰을 발급받아 전송한다. 각각이 무슨 의미인지는 모르겠지만 일단 가져와 보자.

// 로그인 페이지 접속
Connection.Response loginPageResponse = Jsoup.connect("https://tistory.com/auth/login/")
.timeout(3000)
.header("Origin", "http://tistory.com/")
.header("Referer", "https://www.tistory.com/auth/login")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Accept-Language", "ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4")
.method(Connection.Method.GET)
.execute();

// 로그인 페이지에서 얻은 쿠키
Map<String, String> loginTryCookie = loginPageResponse.cookies();

// 로그인 페이지에서 로그인에 함께 전송하는 토큰 얻어내기
Document loginPageDocument = loginPageResponse.parse();

String ofp = loginPageDocument.select("input.ofp").val();
String nfp = loginPageDocument.select("input.nfp").val();

첫 번째 포스트와 마찬가지지만 필요한 헤더를 작성하고 get() 이나 post() 메소드가 아니라 execute() 메소드를 이용해 Document보다 상위 객체인 Response 객체를 얻어왔다. Response 객체의 cookies() 메소드를 이용해 쿠키를 얻어내고, parse() 메소드로 Document를 얻어낸 후 Document에서 두 가지 토큰을 가져왔다. 

티스토리는 로그인 페이지에 접근하기만 해도 뭔지 모를 쿠키들을 전송해 주기 때문에 로그인 페이지에서부터 쿠키를 가져왔다.



2. 로그인하고 로그인 세션ID 얻어내기

먼저 로그인을 처리하는 URL, 즉 form의 action과 method, 전송할 값들을 알아내야 한다.


form의 method와 action(로그인 처리 URL)


전송해야 하는 파라미터는 "redirectUrl", "loginId", "loginPw", "rememberLoginId"와 

토큰 "ofp", "nfp" 총 여섯 개다.

티스토리는 아주 정직하게 태그에 표시되어 쉽게 알 수 있지만 어떤 사이트는 자바스크립트로 어지럽게 작성되어 있다. 그런 경우는(특히 js가 압축된 경우!) 크롬 개발자도구의 Network 탭을 이용하면 편하다.


위에서 확인한 파라미터를 이용해 Jsoup Connection의 데이터로 추가하고 post로 요청하면 '로그인 된' 세션ID를 얻어낼 수 있다.

// Window, Chrome의 User Agent.
String userAgent = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36";

// 전송할 폼 데이터
Map<String, String> data = new HashMap<>();
data.put("loginId", "아이디");
data.put("password", "비밀번호");
data.put("rememberLoginId", "1");
data.put("redirectUrl", "http://tistory.com/");
data.put("ofp", ofp); // 로그인 페이지에서 얻은 토큰들
data.put("nfp", nfp);

// 로그인(POST)
Connection.Response response = Jsoup.connect("https://www.tistory.com/auth/login")
.userAgent(userAgent)
.timeout(3000)
.header("Origin", "http://tistory.com/")
.header("Referer", "https://www.tistory.com/auth/login")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Accept-Language", "ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4")
.cookies(loginTryCookie)
.data(data)
.method(Connection.Method.POST)
.execute();

// 로그인 성공 후 얻은 쿠키.
// 쿠키 중 TSESSION 이라는 값을 확인할 수 있다.
Map<String, String> loginCookie = response.cookies();

이제 로그인에 성공했다. 얻어낸 이 '로그인 된' 쿠키를 계속 사용하면 된다. 세션ID의 키는 서버사이드 설정에 따라 언어의 기본 값(PHP는 PHPSESSID, JSP는 JSESSIONID 등)이거나 따로 지정한 이름이다. 딱히 중요한 내용은 아니지만 서버사이드 언어를 유추하는 방법 중 하나가 된다.



3. 티스토리 블로그 관리 페이지에서 내 블로그 목록 얻어내기

위에서 얻은 쿠키를 사용한다는 점 외에는 이전 포스트와 차이가 없다. 접속하고 값을 얻어내면 된다. 

// 티스토리 관리자 페이지
Document adminPageDocument = Jsoup.connect("http://partnerjun.tistory.com/admin")
.userAgent(userAgent)
.header("Referer", "http://www.tistory.com/")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept-Encoding", "gzip, deflate, sdch")
.header("Accept-Language", "ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4")
.cookies(loginCookie) // 위에서 얻은 '로그인 된' 쿠키
.get();

// select 내의 option 태그 요소들
Elements blogOptions = adminPageDocument.select("select.opt_blog > option");

// 블로그 이름과 url 얻어내기
for(Element option : blogOptions) {
String blogName = option.text();
String blogUrl = option.attr("abs:value");

System.out.println(blogName); // 간단한 블로그
System.out.println(blogUrl); // http://partnerjun.tistory.com/admin/center/
}


최근 많은 사이트에서는 보안 목적으로 로그인 후에 추가적인 과정을 요구하기도 한다. 대표적인 것은 새로운 기기에서의 로그인 시 이메일 체크나 capcha다. 이메일 체크야 직접 한번 해주면 되지만 capcha는 아직 만만한 문제가 아니다. 기계학습을 이용해 capcha를 해결하는 방법이 나왔다는 이야기를 들었는데 얼마 후면 라이브러리로 제공될지도 모르겠다. 기술의 발전을 기뻐해야 하는 건지, 개발하는 측에 있는 사람으로써 두려움에 떨어야 하는지는 모를 일이다.


아무튼, Jsoup로 로그인하고 '로그인 한' 사용자만 접근 가능한 페이지의 값을 얻어내 보았다. 다음 포스트에서는 XMLHttpRequest 객체를 이용한 Ajax 요청을 Jsoup로 해 보려고 한다(사실 특별한 내용은 없지만 크롬 개발자도구의 Network탭 그림 때문에 분리한다).

[ 해당 포스팅은 http://partnerjun.tistory.com/guestbook 님 티스토리 블로그 내용을 포스팅 한 것임을 밝힙니다. ]



Jsoup는 아주 강력하고 재미있는 라이브러리다. 단순한 HTML 문서 파싱을 넘어 웹 사이트에 대한 Request, Response를 모두 처리할 수 있다. 덕분에 일부 특별한 경우(플래시, 애플릿, ActiveX같은 비표준이나 WebSocket)가 아니라면 브라우저로 사이트를 이용하는 상황을 그대로 재현해낼 수 있다. 다시 말해, 대부분의 사이트의 원하는 정보만 뽑아내는 '뷰어'를 만들 수 있다는 것이다. 몇 가지 간단한 예제를 통해 사이트에서 원하는 정보만 뽑아내는 과정을 적어보려 한다.



0. Gradle 디펜전시 추가

compile group: 'org.jsoup', name: 'jsoup', version: '1.10.2'

Maven Repository를 통해 간단하게 디펜전시를 추가 할 수 있다.


Jsoup는 크게 static 메소드를 체이닝해서 URL(혹은 로컬HTML)에 연결하고 결과를 얻어오는 org.jsoup.Jsoup 패키지와 얻어온 결과의 구조를 위한 객체들이 포함된 org.jsoup.nodes 패키지, 연결 방법과 Response, Request등을 가지고 있는 org.jsoup.Connection 패키지로 이루어져 있다. 



Jsoup의 주요 요소는 크게 다섯 가지로 볼 수 있다.


Document 

 Jsoup 얻어온 결과 HTML 전체 문서

Element

 Document의 HTML 요소

Elements

 Element가 모인 자료형. for나 while 등 반복문 사용이 가능하다.

Connection

 Jsoup의 connect 혹은 설정 메소드들을 이용해 만들어지는 객체, 연결을 하기 위한 정보를 담고 있다.

Response

 Jsoup가 URL에 접속해 얻어온 결과. Document와 다르게 status 코드, status 메시지나 charset같은 헤더 메시지와 쿠키등을 가지고 있다.




Jsoup로 하는 작업은 크게 Connection 객체를 통해 URL에 접속하고(혹은 로컬 파일/문자열), Response 객체에서 세션ID같은 쿠키와 HTML Document를 얻어낸 후, Document의 Element들을 파싱하는 과정으로 나누어진다고 볼 수 있다.




1. URL 접속해 결과 얻어오기

URL에 접속해 Document를 얻어내기는 아주 쉽다.

// 간략화된 GET, POST
Document google1 = Jsoup.connect("http://www.google.com").get();
Document google2 = Jsoup.connect("http://www.google.com").post();

// Response로부터 Document 얻어오기
Connection.Response response = Jsoup.connect("http://www.google.com")
.method(Connection.Method.GET)
.execute();
Document google3 = response.parse();

http://www.google.com에 접속하는 방법들



얻어낸 Document는 두가지 방법으로 출력할 수 있다. .html() 메소드와 .text() 메소드 두 가지다.

Connection.Response response = Jsoup.connect("http://www.google.com")
.method(Connection.Method.GET)
.execute();
Document document = response.parse();

String html = document.html();
String text = document.text();

html과 text는 JQuery의 메소드와 유사하다. 문서의 html 그 자체를 가져올지, html 태그 사이의 문자열만을 가져올지를 택하는 것이다.



document.html()의 결과

<!doctype html>

<html itemscope itemtype="http://schema.org/WebPage" lang="ko">

 <head>

  <meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image">

  <link href="/images/branding/product/ico/googleg_lodp.ico" rel="shortcut icon">

  <meta content="origin" id="mref" name="referrer">

  <title>Google</title> 

  <script>(function(){window.google={kEI:'P7QKWeJtioTzBfTmg6AB',kEXPI:'1352553,3700294,3700347,4029815,4031109,4032677,4036527,4038214,4038394,4039268,4041776,4043492,4045096,4045293,4045841,4046904,4047140,4047454,4048347,4048980,4050750,4051887,4056126,4056682,4058016,4061666,4061980,4062724,4064468,4064796,4065786,4069829,4071757,4071842,4072270,4072364,4072774,4076095,4076999,4078430,4078588,4078763,4080760,4081038,4081165,4082131,4082230,4083046,4090550,4090553,4090806,4091353,4092934,4093313,4093498,4093524,4094251,4094544,4094837,4095910,4095999,4096323,4097150,4097922,4097929,4098096,4098458,4098721,4098728,4098752,4100169,4100174,4100376,4100679,4100714,4100828,4101376,4101429,4101750,4102028,4102032,4102107,4102238,4103215,4103254,4103475,4103845,4103849,4103999,4104202,4104204,4104527,4105085,4105099,4105178,4105317,4105470,4105788,4106085,4106209,4106949,4107094,4107221,4107395,4107422,4107525,4107555,4107628,4107634,4107807,4107895,4107900,4107957,4107966,4107968,4108012,4108016,4108027,4108033,4108417,4108479,4108537,4108539,4108553,4108687,4108885,4109075,4109293,4109316,4109489,4109498,4110094,8300508,8503585,8508113,8508229,8508931,8509037,8509373,8509826,10200083,10200096,19001874,19002112,19002127,41027342',authuser:0,j:{en:1,bv:24,pm:'p',u:'c9c918f0',qbp:0},kscs:'c9c918f0_24'};google.kHL='ko';})();(function(){google.lc=[];google.li=0;google.getEI=function(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||google.kEI};google.getLEI=function(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b};google.https=function(){return"https:"==window.location.protocol};google.ml=function(){return null};google.wl=function(a,b){try{google.ml(Error(a),!1,b)}catch(c){}};google.time=function(){return(new Date).getTime()};google.log=function(a,b,c,d,g){a=google.logUrl(a,b,c,d,g);if(""!=a){b=new Image;var e=google.lc,f=google.li;e[f]=b;b.onerror=b.onload=b.onabort=function(){delete e[f]};window.google&&window.google.vel&&window.google.vel.lu&&window.google.vel.lu(a);b.src=a;google.li=f+1}};google.logUrl=function(a,b,c,d,g){var e="",f=google.ls||"";c||-1!=b.search("&ei=")||(e="&ei="+google.getEI(d),-1==b.search("&lei=")&&(d=google.getLEI(d))&&(e+="&lei="+d));a=c||"/"+(g||"gen_204")+"?atyp=i&ct="+a+"&cad="+b+e+f+"&zx="+google.time();/^http:/i.test(a)&&google.https()&&(google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a};google.y={};google.x=function(a,b){google.y[a.id]=[a,b];return!1};google.lq=[];google.load=function(a,b,c){google.lq.push([[a],b,c])};google.loadAll=function(a,b){google.lq.push([a,b])};}).call(this);

google.j.b=(!!location.hash&&!!location.hash.match('[#&]((q|fp)=|tbs=rimg|tbs=simg|tbs=sbi)'))

||(google.j.qbp==1);(function(){google.hs={h:true,pa:true,q:false};})();(function(){goo


(이하 생략)



document.text()의 결과

Google 스크린 리더 사용자는 여기를 클릭하여 Google 순간 검색을 설정 해제하시기 바랍니다. Gmail 이미지 로그인 Google 순간 검색을 사용할 수 없습니다. 검색어를 입력한 후 Enter를 누르세요. 자세히 알아보기 Google연결 속도 문제로 순간 검색이 중지되었습니다. 검색하려면 Enter를 누르세요. 검색하려면 Enter를 누르세요. 부적절한 예상 검색어 신고 × 한국 'Ok Google'이라고 말하면 음성 검색이 시작됩니다. 손가락 하나 움직이지 않고 검색해 보세요. 'Ok Google' 다음에 말한 내용을 Chrome에서 검색합니다. 자세히 알아보기아니요'Ok Google' 사용 개인정보처리방침 약관 설정 검색 설정 고급검색 기록 검색 도움말 의견 보내기 Google.com 사용 광고 비즈니스 Google 정보 내 계정 검색 지도 YouTube Play 뉴스 Gmail 드라이브 캘린더 Google+ 번역 사진더보기 문서 도서 Blogger 주소록 행아웃 KeepGoogle 제품 모두 보기



이 두가지 메소드는 Document뿐 아니라 Element에도 구현되어 있다. 



2. 얻어온 결과에서 특정 값 뽑아내기

특정 값, 그러니까 특정한 html 요소를 얻으려면 select("css query") 메소드를 사용하면 된다. 

구글 메인 페이지 검색 버튼의 value를 얻어 보자.


검색 버튼의 name은 btnK다.


Connection.Response response = Jsoup.connect("http://www.google.com")
.method(Connection.Method.GET)
.execute();
Document googleDocument = response.parse();
Element btnK = googleDocument.select("input[name=btnK]").first();
String btnKValue = btnK.attr("value");

System.out.println(btnKValue); // Google 검색

select의 결과는 Elements다. 그 중 첫번째 Element를 first() 메소드로 선택했다.





※ 목표가 있는 예제

불법만화로 유명한 그 사이트(머루)의 뷰어를 만든다고 상상해보자.

얻어내야 할 값은 크게 두 가지다.


1. 만화의 목록

2. 만화의 이미지 파일


이 값들을 얻어내기 위해서는 

1) 만화 목록을 얻어낸다. 

2) 글 내용에서 실제 만화 이미지가 있는 링크를 얻어낸다.

3) 이미지가 있는 링크에 접속한 후 이미지를 뽑아낸다. 

이렇게 세 가지 과정으로 진행해 보자.



1) 만화 목록 얻어내기

앞서 살펴본 Jsoup의 Conenction 메소드를 이용해 '업데이트' 페이지에 접속해 Doucment를 얻어낸다.

Document rawData = Jsoup.connect(URL)
.timeout(5000)
.get();

이 불법적이고 무서운 사이트는 Request Header를 검사하지 않는다. 그래서 위 코드처럼 아무런 추가적인 정보 없이 간단하게 결과를 얻어 올 수 있다. 하지만 Request를 철저하게 검사하는 사이트에는 더 많은 정보가 필요하다. 그런 사이트는 다음 글에 적을 예정이다.



아무튼, 이제 얻어낸 Document에서 정보를 뽑아낼 차례다. 구글 크롬의 개발자 도구를 이용해 업데이트 페이지를 확인해 보자.


'업데이트' 페이지 HTML


게시판은 table 태그를 사용하고, 각 행은 tr 태그에 매칭되며 공지사항은 tr_notice 클래스를 가지고 있다는 사실을 알 수 있다. 




tr 태그의 내부


tr 태그에 포함된 요소들을 살펴보자. 

a 태그로 글 내용에 해당하는 url을 얻을 수 있고, a태그의 첫번째 div에서 제목을 얻을 수 있다. 마지막으로 small 태그를 통해 글이 작성된 날짜를 얻을 수 있다. 


이렇게 얻어낸 사실들을 직접 코드로 구현하자.

Elements articles = rawData.select("tr:not(.tr_notice) a"); // 공지사항을 제외한 tr의 a 태그들을 얻어온다.

for(Element article : articles) {

String href = article.attr("abs:href"); // a태그 href의 절대주소를 얻어낸다.

// a 태그 안에 포함된 div들
Elements articleDiv = article.select("div");

String thumbUrl = ROOT_URL
+ articleDiv.first() // 첫 번째 div에서 썸네일 url을 얻어온다.
.attr("style")
.replace("background-image:url(", "")
.replace(")", "");

String title = articleDiv.get(1).ownText(); // 두 번째 div에서 제목을 얻어낸다.

String date = articleDiv.get(1).select("small").text()
.split("\\|")[0];

System.out.println(href); // http://ma../b/mangup/00000
System.out.println(thumbUrl); // http://ma../quickimage/...
System.out.println(title); // 제목
System.out.println(date); // 날짜
}

얻어내고자 한 요소들을 css 선택지로 얻어낸 후, split이나 replace등의 메소드를 이용해 정리한다.


이 '글 목록'에 해당하는 정보는 필요에 맞게 정의한 객체에 담아 보관하거나 유저에게 보여 줄 수 있다.




2) 글 내용에서 만화가 있는 링크 얻어내기

위에서 얻어낸 글 내용 url에 접속한 후, 실제 이미지가 있는 페이지에 접근할 차례다. 태그 분석을 위해 브라우저로 페이지에 직접 들어가 보자.


글 내용 HTML


글의 내용에 해당하는 div(#vContent)의 첫 번째 a 태그의 href 속성이 실제 만화 이미지가 포함된 URL이다.



Document rawData = Jsoup.connect(ARTICLE_URL)
.timeout(5000)
.get();

Elements contentATags = rawData.select("#vContent a"); // 공지사항을 제외한 tr의 a 태그들을 얻어온다.

String viewPageUrl = contentATags.first()
.attr("abs:href"); // 마찬가지로 절대주소 href를 얻어낸다

System.out.println(viewPageUrl); // http://wasabi.../archives/XXXXX...

아주 간단하게 이미지들이 포함된 주소를 얻어낼 수 있다. 




3) 만화 이미지가 있는 URL에 접속해 이미지 URL 얻어내기

마찬가지로 만화 이미지가 포함된 URL에 접속해 태그를 분석한다.


만화 이미지가 있는 페이지의 HTML


html 코드를 보면 이미지들이 가진 특정 클래스가 있다. 이 클래스를 가진 img 태그들을 얻어낸 후, data-src 속성을 뽑아내자.


  Document rawData = Jsoup.connect(VIEWER_URL)
.timeout(5000)
.get();

Elements imgs = rawData.select("img[class=lz-lazyload]"); // lz-lazyload 클래스를 가진 img들

List<String> imageUrls = new ArrayList<>();

for(Element img : imgs) {
imageUrls.add(img.attr("abs:data-src"));
}

System.out.println(imageUrls); // 이미지 URL들.
}


만화 내용이 되는 모든 이미지 URL을 뽑아넀다. 이 URL에 접속해 직접 파일로 다운로드 할 수도, 자기 나름의 뷰어에 출력 할 수도 있다. 또, 목록을 얻어 낼 때 필요한 data들을 포함함으로써 원하는 페이지나 검색까지 구현이 가능하다.



[    스프링과 안드로이드 연동5 : Javascript에서 Android 함수를 호출하기    ]



요즘은 웹을 개발하고 웹뷰를 이용해서 안드로이드에 붙이는 식으로 해서 반응형으로 하이브리드 앱을 만드는 경우가 많은데,


이러한 경우 웹뷰에서 버튼을 클릭한다거나 했을 때 자바스크립트에서 안드로이드에 있는 함수를 호출해서 


안드로이드를 제어하고 싶은 경우가 있다.


----------------------------------------------------------------------------------------------------------------------------------


먼저, 웹뷰를 연결해 세팅부터 하자.


1. [    WebView(웹뷰) 세팅    ]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<span style="font-size: 14pt;">
/ 웹뷰 위젯 연결
        webView1 = (WebView)findViewById(R.id.webView1);
        // 클리기 새창 안뜨게...
        webView1.setWebViewClient(new WebViewClient());
        // 세부 세팅객체 가져오기
        WebSettings mWebSettings = webView1.getSettings();
        // 자바스크립트 사용 허용
        // 웹뷰 내에서 자바스크립트 실행해 자바스크립트에서 안드로이드 함수
        // 실행시킬려면 필수로 세팅필요
        mWebSettings.setJavaScriptEnabled(true);
        // 안드로이드에서 제공하는 줌 아이콘을 사용할 수 있도록 설정
        mWebSettings.setBuiltInZoomControls(true);
        // 캐시 사용 여부 설정
        mWebSettings.setAppCacheEnabled(false);
 
        // 로드할 주소를 입력
        webView1.loadUrl("http://192.168.0.8:8080/");
</span>

일단 스프링 웹 프로젝트에서 작성한 웹 페이지를 띄우는 웹뷰를 작성한다.

그 다음에 


2. 자바스크립트에서 호출시 수행할 안드로이드 메서드를 작성한다.


이때, 자바스크립트와 안드로이드를 중간에서 인터페이스 역할을 할 클래스를 작성해서 그 내부에

메서드를 정의하도록 한다.


당연히 인터넷 작업을 해야함으로

<!-- 인터넷 접속 권한 추가 -->
<uses-permission android:name="android.permission.INTERNET" />

를 manifest에 추가해주어야 하고

네트워크 작업은 백그라운드 쓰레드로 해야하며


백그라운드 쓰레드에서는 메인 뷰의 화면 제어를 할 수 없음으로

handler에게 대신해달라고 요청을 해야한다.


앞에선 계속 그렇게 해왔는데 이 2가지를 한번에 하는 것이


handler.post(new Runnable(){ run() }) 을 이용한 방식이다.


이런식으로해서 JavascriptInterface 클래스를 만들도록 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<span style="font-size: 14pt;">
package com.example.kscs.androidspringconnection1;
 
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.TextView;
 
public class WebViewActivity extends AppCompatActivity {
 
    WebView webView1;
    Handler handler = new Handler();
    TextView textView;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_web_view);
 
        // 웹뷰 위젯 연결
        webView1 = (WebView)findViewById(R.id.webView1);
        // 클리기 새창 안뜨게...
        webView1.setWebViewClient(new WebViewClient());
        // 세부 세팅객체 가져오기
        WebSettings mWebSettings = webView1.getSettings();
        // 자바스크립트 사용 허용
        // 웹뷰 내에서 자바스크립트 실행해 자바스크립트에서 안드로이드 함수
        // 실행시킬려면 필수로 세팅필요
        mWebSettings.setJavaScriptEnabled(true);
        // 안드로이드에서 제공하는 줌 아이콘을 사용할 수 있도록 설정
        mWebSettings.setBuiltInZoomControls(true);
        // 캐시 사용 여부 설정
        mWebSettings.setAppCacheEnabled(false);
 
        // 로드할 주소를 입력
        webView1.loadUrl("http://192.168.0.8:8080/");
 
        // 텍스트 뷰 위젯 연결
        textView = (TextView)findViewById(R.id.textView);
 
    }
 
    final class JavascriptInterface {
        @android.webkit.JavascriptInterface  // 최근에는 이 어노테이션을 붙여줘야 동작하게 되어 있다..
        public void callMethodName(final String str){ // 반드시 final이어야 한다.
            // 네트워크를 통한 작업임으로 백그라운드 스레드를 써서 작업해야한다.
            // 또한, 백그라운드 스레드는 직접 메인 뷰에 접근해 제어할 수 없음으로
            // 핸들러를 통해서 작업해야하는데
            // 이 때문에 한번에 handler.post()를 통해서 내부에 Runnable을 구현해 작업한다.
            handler.post(new Runnable() {
                @Override
                public void run() {
                    // handle를 통해서 화면에 접근하는 것임으로 가능함
                    textView.setText("자바스크립트에서 전달받은 문자열을 쓴다 : " + str);
                }
            });
        }
    }
 
}
 
 
 
</span>



3. 만들어준 JavascriptInterface 클래스를 웹뷰에 등록해 주어야 한다.


1
2
3
<span style="font-size: 14pt;">
webView1.addJavascriptInterface(new JavascriptInterface(),"myJSInterfaceName");
</span>

이때 중요한 점은 

webView1.addJavascriptInterface(new JavascriptInterface(),"myJSInterfaceName");

에서 두번째 매개변수란에 myJSInterfaceName 처럼 인터페이스 이름을 지정하게 되어 있는데

이 이름을 이용해서 자바스크립트에서 호출하게 된다.



4. 스프링 웹 프로젝트의 자바스크립트에서 특정 이벤트 발생시 안드로이드 함수를 호출하는 구문을 작성하자.


가장 중요한 부분은 window.myJSInterfaceName.callMethodName(str); 부분이다.

window.(지정한 javascript인터페이스명).수행할메서드명() 으로 호출하게 된다.

function callAndroid(){
    var str = document.getElementById("txtName").value;
    window.myJSInterfaceName.callMethodName(str);
}

<form id="formName" action="">
    <input id="txtName" type="text" />
    <button onclick="javascript:callAndroid()">호출하기</button>
 
</form>
1
2
3
4
5
6
7
8
9
10
11
12
<span style="font-size: 14pt;">
function callAndroid(){
    var str = document.getElementById("txtName").value;
    window.myJSInterfaceName.callMethodName(str);
}
 
<form id="formName" action="">
    <input id="txtName" type="text" />
    <button onclick="javascript:callAndroid()">호출하기</button>
  
</form>
</span>


여기까지 했다면, 


웹뷰 상에서 안드로이드 사용자가 "호출하기" 버튼을 클릭시에 callAndroid() 자바스크립트 메서드가 수행되고


해당 자바스크립트 메서드에서 window.myJSInterfaceName.callMethodName(str); 을 통해 안드로이드 메서드를 

호출하여, TextView에 있는 메시지를 웹뷰를 통해 입력한 값으로 세팅하게 된다.



안드로이드 쪽 전체 코드는 다음과 같다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.example.kscs.androidspringconnection1;
 
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.TextView;
 
public class WebViewActivity extends AppCompatActivity {
 
    WebView webView1;
    Handler handler = new Handler();
    TextView textView;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_web_view);
 
        // 웹뷰 위젯 연결
        webView1 = (WebView)findViewById(R.id.webView1);
        // 클리기 새창 안뜨게...
        webView1.setWebViewClient(new WebViewClient());
        // 세부 세팅객체 가져오기
        WebSettings mWebSettings = webView1.getSettings();
        // 자바스크립트 사용 허용
        // 웹뷰 내에서 자바스크립트 실행해 자바스크립트에서 안드로이드 함수
        // 실행시킬려면 필수로 세팅필요
        mWebSettings.setJavaScriptEnabled(true);
        // 안드로이드에서 제공하는 줌 아이콘을 사용할 수 있도록 설정
        mWebSettings.setBuiltInZoomControls(true);
        // 캐시 사용 여부 설정
        mWebSettings.setAppCacheEnabled(false);
 
        // 로드할 주소를 입력
        webView1.loadUrl("http://192.168.0.8:8080/");
 
        // 텍스트 뷰 위젯 연결
        textView = (TextView)findViewById(R.id.textView);
 
        webView1.addJavascriptInterface(new JavascriptInterface(),"myJSInterfaceName");
    }
 
    final class JavascriptInterface {
        @android.webkit.JavascriptInterface
        public void callMethodName(final String str){ // 반드시 final이어야 한다.
            // 네트워크를 통한 작업임으로 백그라운드 스레드를 써서 작업해야한다.
            // 또한, 백그라운드 스레드는 직접 메인 뷰에 접근해 제어할 수 없음으로
            // 핸들러를 통해서 작업해야하는데
            // 이 때문에 한번에 handler.post()를 통해서 내부에 Runnable을 구현해 작업한다.
            handler.post(new Runnable() {
                @Override
                public void run() {
                    // handle를 통해서 화면에 접근하는 것임으로 가능함
                    textView.setText("자바스크립트에서 전달받은 문자열을 쓴다 : " + str);
                }
            });
        }
    }
 
}

[    스프링과 안드로이드 연동4 : JSON으로 가져오기    ]



이번에는 안드로이드에서 스프링 프로젝트로 요청시 JSON으로 서버에서 제공하는 데이터를 가져오는 방식을 다루어 보겠습니다.


1. 먼저, 스프링 프로젝트에서 pom.xml에 라이브러리를 추가합니다.


1) jackson-databind 추가

: @ResponseBody로 반환시 필요

<dependency>

<groupId>com.fasterxml.jackson.core</groupId>

<artifactId>jackson-databind</artifactId>

<version>2.8.7</version>

   </dependency>


2) jason-simple 추가

: JSONObject와 같은 json 객체 생성시 필요

<dependency>

<groupId>com.googlecode.json-simple</groupId>

<artifactId>json-simple</artifactId>

<version>1.1.1</version>

  </dependency>


2. 다음으로 스프링 컨트롤러 부분을 작성합니다. 스프링 컨트롤러에서 다음의 코드를 입력합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<span style="font-size: 12pt;">
@RequestMapping(value="json.do",produces="application/json;charset=utf-8")
     public @ResponseBody JSONObject json(){
         // json-simple 라이브러리 추가 필요(JSON 객체 생성)
         JSONObject jsonMain = new JSONObject(); // json 객체
         // {변수명:값, 변수명:값}
         // {sendData:[{변수명:값},{변수명:값},...]}
         List<BookDTO> items = bookService.bookList();
         JSONArray jArray = new JSONArray(); // json배열
         for(int i=0; i<items.size(); i++){
             BookDTO dto = items.get(i);
             JSONObject row = new JSONObject();
             // json객체.put("변수명",값)
             row.put("book_code", dto.getBook_code());
             row.put("book_name", dto.getBook_name());
             row.put("press", dto.getPress());
             row.put("price", dto.getPrice());
             row.put("amount", dto.getAmount());
             // 배열에 추가
             // json배열.add(인덱스,json객체)
             jArray.add(i,row);
         }
         // json객체에 배열을 넣음
         jsonMain.put("sendData", jArray);
         return jsonMain;
     }
</span>

http://localhost:8080/json.do 로 요청시 필요한 데이터를 JSONObject에 저장시켜 반환하는 컨트롤러 부분입니다.

반환시 한글 인코딩 문제 때문에 produces 부분에서 JSON 객체 생성시 utf-8로 인코딩하겠다는 설정을 하고,

JSONObject는 {변수명:값} 을 담을 수 있음으로

JSONArray로 배열 객체를 생성해 여기에 JSONObject 객체를 담아서

sendData라는 변수명으로 이 배열을 저장시켜 JSON으로 반환하게 됩니다.




2. 안드로이드에서 작업


안드로이드에서도 JSON 객체를 받아오기 위해서 2개의 라이브러리를 추가해주어야 합니다.

1. httpclient-4.2.2.jar

2. httpcore-4.2.2.jar

두개의 라이브러리를 mvnrepository 에서 검색해서 files 부분에 보면 다운로드를 할 수 있는데 다운로드 합니다.

그리고 안드로이드 프로젝트에서 프로젝트 보기가 Android로 되어 있는데 이 부분을 눌러 Project로 변경합니다.

그러면, App/libs 폴더가 보일겁니다.

여기 Android일 땐 보이지 않지만

Project로 변경시 app/libs 폴더가 보입니다. 그러면 이 폴더에 다운받은 2개의 라이브러리를 집어 넣습니다.


다음으로, android 형식으로 보기를 다시 누르고, Gradle Scripts 의 build.gradle 부분에 들어가서


dependency 부분에 


다음의 2줄을 추가해줍니다.

compile files('libs/httpclient-4.2.2.jar')
compile files('libs/httpcore-4.2.2.jar')


그리고 Sync Now 선택해주면 적용이 된 것입니다.



3. 이제 안드로이드에서 코드를 작성합니다.


역시 네트워크 작업임으로 이전에서 했던 3가지 사항을 준수합니다.


1) 인터넷 사용 권한 설정(manifest에)

<!-- 인터넷 접속 권한 추가 -->
<uses-permission android:name="android.permission.INTERNET" />


2) 네트워크 작업임으로 백그라운드 스레드에서 작업할 것


3) 백그라운드 스레드에선 메인 뷰에 접근할 수 없음으로 핸들러를 통해 뷰에 접근할 것!




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.example.kscs.androidspringconnection1;
 
import android.graphics.Bitmap;
import android.os.Handler;
import android.os.Message;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.ListView;
 
import com.example.kscs.androidspringconnection1.dto.BookDTO;
 
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONObject;
 
import java.util.ArrayList;
import java.util.List;
 
public class JsonActivity extends AppCompatActivity implements Runnable{
 
    // 1. 변수 선언
    ListView listView1;
    List<BookDTO> items;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_json);
 
        // 2. 위젯 연결
        listView1 = (ListView)findViewById(R.id.listView1);
        items = new ArrayList<>();
 
        // 3. 백그라운드 스래드 생성
        Thread th = new Thread(this);
        th.start();
    }
 
    // 네트워크 작업임으로 백그라운드 스레드로 동작시키기 위함(의무)
    @Override
    public void run() {
        try{
            // httpclient-4.2.2.jar와 httpcore-4.2.2.jar를 mvnrepository에서 찾아 Files에 Download jars를 해 받음
            // app/libs에 추가
            // 이때, android상태에서는 libs가 안보임으로 project로 변경해서
            // lib에 받은 jar파일 2개를 넣어준다.
 
            // http client 객체
            HttpClient http = new DefaultHttpClient();
            // post 방식으로 전송하는 객체
            HttpPost httpPost = new HttpPost("http://192.168.0.8:8080/spring01/json.do");
            // http클라이언트.execute(httppost객체) : 웬서버에 데이터를 전달
            // 결과(json)가 response로 넘어옴
            HttpResponse response = http.execute(httpPost);
            // body에 json 스트링이 넘어옴
            String body = EntityUtils.toString(response.getEntity());
            // string을 JSONObject로 변환
            JSONObject jsonObj = new JSONObject(body);
            // json객체.get("변수명")
            JSONArray jArray = (JSONArray)jsonObj.get("sendData");
            for(int i=0; i<jArray.length();i++){
                // json배열.getJSONObject(인덱스)
                JSONObject row = jArray.getJSONObject(i);
                BookDTO dto = new BookDTO();
                dto.setAmount(row.getInt("amount"));
                dto.setBook_code(row.getInt("book_code"));
                dto.setBook_name(row.getString("book_name"));
                dto.setPress(row.getString("press"));
                dto.setPrice(row.getInt("price"));
                dto.setAmount(row.getInt("amount"));
                // ArrayList에 add
                items.add(dto);  
            }
             // 핸들러에게 메시지를 요청
            handler.sendEmptyMessage(0);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
    // 핸들러
    Handler handler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            // 어댑터 생성
            String[] str = new String[items.size()];
            for(int i=0; i<str.length; i++){
                BookDTO dto = items.get(i);
                str[i] = dto.getBook_name() + "(" + dto.getPress() + ")";
            }
            ArrayAdapter<String> adapter = new ArrayAdapter<String>(JsonActivity.this,android.R.layout.simple_list_item_1,str);
            // adapter와 data 바인딩
            listView1.setAdapter(adapter);
        }
 
    };
}


**** 마지막으로.........

위의 경우에서는 안드로이드에서 스프링 컨트롤러로 post로 요청을 보낼 때 데이터를 같이 보내지 않았다. 만약 데이터를 같이 넘겨줘야 하는 경우에는

// NameValuePair : 변수명과 값을 함께 저장하는 객체로 제공되는 객체이다.
ArrayList<NameValuePair> postData = new ArrayList<>();
// post 방식으로 전달할 값들을 postData 객체에 집어 넣는다.
postData.add(new BasicNameValuePair("id","아이디"));
postData.add(new BasicNameValuePair("pw","패스워드"));
// url encoding이 필요한 값들(한글, 특수문자) : 한글은 인코딩안해주면 깨짐으로 인코딩을 한다.
UrlEncodedFormEntity request = new UrlEncodedFormEntity(postData,"utf-8");
HttpPost httpPost = new HttpPost(url);
// post 방식으로 전달할 데이터 설정
httpPost.setEntity(request);
// post 방식으로 전송, 응답결과는 response로 넘어옴
HttpResponse response = http.execute(httpPost);
// response text를 스트링으로 변환
String body = EntityUtils.toString(response.getEntity());
// 스트링을 json으로 변환한다.
JSONObject obj = new JSONObject(body);

// 스프링 컨트롤러에서 리턴해줄 때 저장했던 값을 꺼냄
String message = obj.getString("message");

과 같이 작성하면 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// NameValuePair : 변수명과 값을 함께 저장하는 객체로 제공되는 객체이다.
ArrayList<NameValuePair> postData = new ArrayList<>();
// post 방식으로 전달할 값들을 postData 객체에 집어 넣는다.
postData.add(new BasicNameValuePair("id","아이디"));
postData.add(new BasicNameValuePair("pw","패스워드"));
// url encoding이 필요한 값들(한글, 특수문자) : 한글은 인코딩안해주면 깨짐으로 인코딩을 한다.
UrlEncodedFormEntity request = new UrlEncodedFormEntity(postData,"utf-8");
HttpPost httpPost = new HttpPost(url);
// post 방식으로 전달할 데이터 설정
httpPost.setEntity(request);
// post 방식으로 전송, 응답결과는 response로 넘어옴
HttpResponse response = http.execute(httpPost);
// response text를 스트링으로 변환
String body = EntityUtils.toString(response.getEntity());
// 스트링을 json으로 변환한다.
JSONObject obj = new JSONObject(body);
// 스프링 컨트롤러에서 리턴해줄 때 저장했던 값을 꺼냄
String message = obj.getString("message");


[  스프링과 안드로이드 연동3 : 서버에서 XML로 반환해 안드로이드로 가져오기 ]


먼저, 이 작업을 하기 위해서 Spring 프로젝트에서 pom.xml에 3가지 라이브러리를 추가해주어야 한다.

[필요 라이브러리]


1.org.jdom을 mvnrepoisitory에서 검색해 dependency를 pom.xml에 추가한다.

-> 자바 객체를 가지고 xml을 생성해주는 라이브러리


2.json-simple 도 추가

-> json 객체를 생성


3.jackson-databind 추가

-> 스프링 컨트롤러에서 @ResponseBody로 json으로 전송하기 위해 사용



3개의 라이브러리를 pom.xml에 추가했으면 다음으로 스프링 컨트롤러에서


XML로 보낼 데이터를 구성해 전달하는 코드를 작성해 주어야 한다.

(mvnrepository에서 검색해서 추가하자.)


그 다음 아래는 서버쪽 컨트롤러 코드에 대한 부분이다.


bookService.bookList()를 통해서 db에서 BookDTO 객체를 갖는 List를 반환받아서 이 List 정보를 XML 형태로 만들어서

return하는 내용의 코드이다. XML을 구성하는 부분은 주석으로 남겨 놓았다.


List<BookDTO> 를 가지고 구성할 XML 의 구성은 아래와 같다.


<books>
<book>
<book_code>1</book_code>
<book_name>자바</book_name>
<press>출판사</press>
</book>
</books>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@ResponseBody // 리턴 자료형을 xml or json으로 반환
     @RequestMapping(value="xml.do",produces="application/json;charset=utf-8")
     //    json으로 만들고 utf-8 인코딩을 적용(한글깨지는 것 방지)
     public String xml(){
          List<BookDTO> items = bookService.bookList();
       //  org.jdom2 패키지
       //  root 노드(태그)
         Element root = new Element("books");
       //   xml 문서 객체
         Document doc = new Document(root);
       //  xml 문서에 root 노드 설정, detach() : 새로운 문서를 만들기 전에 클리어(초기화)시킴
         doc.setRootElement(root.detach());
          String result = "";
         for(BookDTO dto : items){
        //      태그 생성 : <book>텍스트</book>
              Element data = new Element("book");
              Element book_code = new Element("book_code");
              book_code.setText(dto.getBook_code()+"");
              Element book_name = new Element("book_name");
              book_name.setText(dto.getBook_name());
              Element press = new Element("press");
              press.setText(dto.getPress());
              Element price = new Element("price");
              price.setText(dto.getPrice());
              Element amount = new Element("amount");
              amount.setText(dto.getAmount()+"");
              data.addContent(book_code);
              data.addContent(book_name);
              data.addContent(press);
              data.addContent(price);
              data.addContent(amount);
         }
         //Document 타입을 스트링으로 변환
         // xml 출력을 위한 객체
         XMLOutputter xout = new XMLOutputter();
          Format f = xout.getFormat(); // org.jdom2 xml 문서의 포맷설정
          f.setEncoding("utf-8"); //  인코딩 방식 설정
          f.setIndent("\t");  // 들여쓰기 문자
          f.setLineSeparator("\r\n"); // 줄바꿈 문자
          f.setTextMode(Format.textMode.TRIM);    // 공백제거
          xout.setFormat(f);  // 문서 포맷 적용
          result = xout.outputString(doc);    // 문자열로 변환
        return result;
     }



여기까지 했으면 스프링 프로젝트에서 필요한 부분은 끝이났다.

이제 안드로이드에서 스프링에서 던진 데이터를 저장하기 위한 BookDTO 클래스를 만들자



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.kscs.androidspringconnection1.dto;
 
/**
 * Created by kscs on 2017-06-04.
 */
 
public class BookDTO {
    private int book_code;
    private String book_name;
    private String press;
    private int price;
    private int amount;
    // getter setter
 
    public int getBook_code() {
        return book_code;
    }
 
    public String getBook_name() {
        return book_name;
    }
 
    public String getPress() {
        return press;
    }
 
    public int getPrice() {
        return price;
    }
 
    public int getAmount() {
        return amount;
    }
 
    public void setBook_code(int book_code) {
        this.book_code = book_code;
    }
 
    public void setBook_name(String book_name) {
        this.book_name = book_name;
    }
 
    public void setPress(String press) {
        this.press = press;
    }
 
    public void setPrice(int price) {
        this.price = price;
    }
 
    public void setAmount(int amount) {
        this.amount = amount;
    }
 
    @Override
    public String toString() {
        return "BookDTO{" +
                "book_code=" + book_code +
                ", book_name='" + book_name + '\'' +
                ", press='" + press + '\'' +
                ", price=" + price +
                ", amount=" + amount +
                '}';
    }
}


그리고 마지막으로 안드로이드에 관련된 작업이다.

역시 네트워크 작업임으로 앞에서 한 예제에서 주의해야하는 3가지 사항을 지켜주어야 한다.

1. 네트워크 작업임으로 백그라운드 스레드로 동작시켜야한다.

2. 네트워크 접근이 필요함으로 인터넷 권한을 주어야한다.

3. 백그라운드 스레드에서 메인 뷰에 접근할 수 없음으로 핸들러를 통해 처리해야한다.


위 사항을 기반으로해서 XMLPullparser를 이용해 데이터를 끄집어 내 핸들러를 통해 안드로이드에 출력해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
package com.example.kscs.androidspringconnection1;
 
import android.os.Handler;
import android.os.Message;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.support.v7.widget.ListViewCompat;
import android.widget.ArrayAdapter;
import android.widget.ListView;
 
import com.example.kscs.androidspringconnection1.dto.BookDTO;
 
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserFactory;
 
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
 
public class XmlActivity extends AppCompatActivity implements Runnable
{
 
    // 1. 변수 선언
    ListView list1;
    List<BookDTO> items;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_xml);
 
        // 2. 위젯 연결
        list1 = (ListView)findViewById(R.id.listView1);
        items = new ArrayList<>();
 
 
        // 3. 스레드 객체 생성
        Thread th = new Thread(this);
        th.start();
    }
 
    // 네트워크 작업임으로 백그라운드 스레드로 동작
    @Override
    public void run() {
        // 네트워크에 접속, xml 리턴
        try{
            // 스프링 컨트롤러에서 xml로 만든 json을 리턴하는 RequestMapping url
            URL url = new URL("http://192.168.0.9/spring01/xml.do");
            // xml 분석기 객체 생성
            XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
            // 스트림 생성
            InputStream is = url.openStream();
            // xml 분석기에 스트림을 입력
            parser.setInput(is,"utf-8");
            // xml 문서의 이벤트(태그의 시작, 끝 등)
            int eventType = parser.getEventType();
            String tag;
            BookDTO dto =  null;
            while( eventType != XmlPullParser.END_DOCUMENT ){
                switch (eventType){
                    case XmlPullParser.START_TAG: // 시작 태그
                        tag = parser.getName(); // 태그의 이름
                        if ( tag.equals("book_name")){
                            dto = new BookDTO();
                            // 태그의 text를 가져와 세팅
                            dto.setBook_name(parser.nextText());
                        }
                        // <press>영진</press>
                        if(tag.equals("press")){
                            dto.setPress(parser.nextText());
                        }
                        break;
                    // </book>
                    case XmlPullParser.END_TAG:
                        tag = parser.getName();
                        if(tag.equals("book")){
                            items.add(dto);
                            dto = null;
                        }
                        break;
                }
                eventType = parser.next(); // 다음으로 이동
            }
            // 여기까지 수행하면 xml분석해서 List에 추가가 완료!
        }catch (Exception e){
            e.printStackTrace();
        }
        // 핸들러에게 메시지 호출(리스트뷰 갱신처리)
        // handlerMessage()가 호출됨
        handler.sendEmptyMessage(1);
    }
    Handler handler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            // 리스트뷰에 출력
            String[] books = new String[items.size()];
            for(int i=0; i<books.length; i++){
                BookDTO dto = items.get(i);
                books[i] = dto.getBook_name() + "(" + dto.getPress() + ")";
            }
            // 어댑터 생성
            ArrayAdapter<String> adapter = new ArrayAdapter<String>(XmlActivity.this,android.R.layout.simple_list_item_1,books);
            // 어댑터와 데이터 바인딩
            list1.setAdapter(adapter);
        }
    };
}


[    스프링과 안드로이드 연동2 : 서버에서 안드로이드로 이미지 가져오기    ]





이전 포스팅과 마찬가지로 네트워크를 통한 작업임으로 주의해야할 사항이 2가지 있다.



1. 안드로이드는 네트워크 작업시 "백그라운드 스레드"로 동작을 시켜야한다.(대용량 데이터를 네트워크로 가져오는 동안 다른 작업은 할 수 없게 될 수 있음으로...)

2. 백그라운드 스레드에서는 메인 뷰 화면을 제어할 수 없다. 따라서 가져온 이미지를 백그라운드 스레드에서 적용할 수가 

없다. 따라서, 핸들러에게 대신 요청을 해주어야 한다.

3. 인터넷을 사용해야 함으로 manifest에 인터넷 사용 권한을 추가해 주어야 한다.



라는 사항을 유의하고 작업하면 그 외의 부분은 이전과 거의 유사하다.

나머지는 코드의 주석을 통해 설명하겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.example.kscs.androidspringconnection1;
 
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.os.Message;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
 
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
 
public class ImageDownActivity extends AppCompatActivity
                                implements Runnable{
 
    // 1. 변수 선언
    Button button1;
    ImageView img1;
    Bitmap bitmap; // 비트맵 객체
    // 메인 스레드와 백그라운드 스레드 간의 통신
    Handler handler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            // 서버에서 받아온 이미지를 핸들러를 경유해 이미지뷰에 비트맵 리소스 연결
            img1.setImageBitmap(bitmap);
        }
    };
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.image_down);
 
        // 2. 위젯 연결
        button1 = (Button)findViewById(R.id.button1);
        img1 = (ImageView)findViewById(R.id.img1);
 
        // 3. 서버에 이미지를 하나 넣어놓자.
        // /resources/images/이미지.png 처럼 올려 놓자.
        // 그냥... 환경설정에 자원 경로로 등록 된 곳에 올리면 된다.
 
        // 4. 버튼 클릭 이벤트
        button1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 네트워크 연결 작업시 이미지 가져오는 것도 대량의 데이터임으로
                // 반드시!! 백그라운드 스레드에서 작업해야한다.
                // 또한, 역시 마찬가지로 백그라운드 스레드는 메인뷰 화면은 제어할 수 없어
                // 핸들러에게 부탁해서 작업해야함!
                // + 인터넷 접속 권한 필요!(MAINFEST에 추가)
 
                // 백그라운드 스레드 생성
                Thread th = new Thread(ImageDownActivity.this);
                // 동작 수행
                th.start();
            }
        });
 
    }
 
    // 백그라운드 스레드
    @Override
    public void run() {
        URL url = null;
        try{
            // 스트링 주소를 url 형식으로 변환
            url = new URL("http://192.168.0.127/resources/images/like1.png");
            // url에 접속 시도
            HttpURLConnection conn = (HttpURLConnection)url.openConnection();
            conn.connect();
            // 스트림 생성
            InputStream is = conn.getInputStream();
            // 스트림에서 받은 데이터를 비트맵 변환
            // 인터넷에서 이미지 가져올 때는 Bitmap을 사용해야함
            bitmap = BitmapFactory.decodeStream(is);
 
            // 핸들러에게 화면 갱신을 요청한다.
            handler.sendEmptyMessage(0);
            // 연결 종료
            is.close();
            conn.disconnect();
 
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}


[    스프링(Spring)과 안드로이드(Android)를 연동해 서버 Html 소스 가져오기    ]


이번에는 스프링과 안드로이드를 연동해서 하는 예제 중 첫번째인 요청한 서버의 Html 소스코드를 가져오는 예제를 작성하겠습니다.


    1. 첫번째로 네트워크를 통해 가져와야함으로 "인터넷 권한을 설정해야 합니다."


manifest파일에 <!-- 인터넷 접속 권한 추가 -->

<uses-permission android:name="android.permission.INTERNET" /> 을 추가해줍니다.




2. 다음으로, 안드로이드 네트워크 작업시에 주의해야할 사항이 2가지 있습니다.



1) 안드로이드에서는 네트워크를 통해 작업을 할 때는 "반드시" "백그라운드 스레드"를 통해서 작업이 이루어져야합니다.


: 왜냐하면, 네트워크로 이미지 등을 받아올 때 시간이 다소 걸릴 수 있는데 메인스레드에서 작업할 시 다른 작업에도

지장을 주기 때문에 백그라운드에서 동작시키지 않을 경우 동작하지 않도록 제한이 걸려져 있습니다.


따라서, 쓰레드를 별도로 구현해 run() 메서드 내부에서 작업을 해야합니다.



2) 다음으로 알아둬야 할 사항은 "백그라운드 스레드"에서는 메인 스레드의 "뷰"를 제어할 수 없다는 것입니다.


예를들어, 백그라운드 스레드의 run()메서드 내에서 URL에 연결을 해 데이터를 가져오고 그 데이터를


이 run() 메서드 내부에서 textView같은 곳에 setText()해서 붙인다면 오류가 발생합니다. 이처럼 안드로이드는 백그라운드 스레드에서


메인스레드의 뷰에 접근할 수 없도록 제한이 걸려있습니다.


따라서 이러한 제한때문에 필요한 것이 "Handler"입니다.



"Handler란?"


: 핸들러는 안드로이드 네트워크 작업시 백그라운드에서 수행해야하는데 백그라운드 스레드에선 메인 뷰를 제어할 수 없기 때문에


백그라운드와 메인 스레드 사이의 통신(중계)를 담당하게 됩니다.


즉, 백그라운드 스레드는 핸들러에게 "네가 대신 화면 제어를 해줘"라고 sendMessage() 메서드를 통해 요청을 하게 됩니다.


그러면, 핸들러는 이 메시지를 받아 처리할 작업을 대신해주게 되는 것입니다.





3. 이제 이러한 이론적 지식을 가지고 아래의 구현 예제를 보겠습니다.



다음은 스프링 웹 서버로 요청해 해당 뷰의 html 소스를 가져오는 과정을 담은 예제입니다.


세부 부분에 대한 설명은 주석으로 남겨놓았습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package com.example.kscs.androidspringconnection1;
 
import android.os.Handler;
import android.os.Message;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
 
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
 
// 안드로이드에서 네트워크 작업시 주의 사항
// 1."반드시, 백그라운드 스레드로 작업해야한다."
//      시간이 많이 걸리기 때문에 메인스레드에서 할 수 없게 되어 있다.
// 2. 백그라운드 스레드에서는 메인화면에 접근 할 수 없다.
public class HtmlActivity extends AppCompatActivity
                            implements Runnable{
 
    // 1. 변수 선언
    Button button1;
    EditText edit1;
    String str;
 
    // android.os.Handler
    // 핸들러 : 백그라운드 쓰레드에서는 직접 메인 화면에 접근할 수 없기 때문에
            // 백그라운드가 핸들러에게 화면 제어를 요청해서 핸들러가 대신 수행하게 된다.(이럴때 사용)
    // 즉, 백그라운드 스레드와 메인스레드의 통신(중계)을 담당한다.
    Handler handler = new Handler(){
        // Alt + Enter -> Override Methods
        // 백그라운드 스레드에서 전달된 메시지를 처리하는 method
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            edit1.setText( str );
        }
    };
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.html);
 
        // 2. 위젯 생성
        button1 = (Button)findViewById(R.id.button1);
        edit1 = (EditText)findViewById(R.id.editText);
 
        // 3. 이벤트 처리
        button1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 메인스레드 외에 백그라운드에서 실행되는 스레드 추가
                Thread th = new Thread(HtmlActivity.this);
                th.start();
            }
        });
    }
 
    // 백그라운드 스레드
    @Override
    public void run() {
        // http://192.168.0.127:8080/
        str = download();
 
        // 에러가 발생함(백그라운드 스레드에서는 메인 UI를 수정할 수 없다.)
        // Only the original thread that created a view hierarchy can touch its views
        // + 인터넷 권한을 주어야 한다. Manifest에 가서 인터넷 접속 권한 추가!
        //edit1.setText(str); // 즉, 핸들러를 통해서 화면 제어를 요청해야한다.
        handler.sendEmptyMessage(0); // sendEmptyMessage( 핸들러에게 전달할 메시지 )
        // 핸들러에 여러 메시지를 보내며 요청하는 경우 메시지의 종류에 따라 처리할 로직을 분리시켜줘야한다.
    }
    // html 소스 다운로드용 메서드
    String download(){
        StringBuffer sb = new StringBuffer();
        try{
            // 호출할 spring 서버측 주소 입력(URL주소 형태로 객체화)
            URL url = new URL("http://192.168.0.127:8080/");
            // url에 접속 객체 가져오기
            HttpURLConnection conn = (HttpURLConnection)url.openConnection();
 
            if ( conn != null ){
                // 연결 timeout 시간 설정(밀리세컨드 단위) : 지정된 시간 이후 연결 종료
                conn.setConnectTimeout(5000);
                // 캐시 사용 여부 설정
                conn.setUseCaches(false);
                // http status code 상태코드, 200 - success
                if ( conn.getResponseCode() == 200 ){
                    // conn.getInputStream() : InputStream을 리턴받음
                    BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(),"utf-8"));
                    for(;;){ // 무한 반복
                        String line = br.readLine(); // 한줄 읽기
                        if(line == null) break; // 더이상 내용이 없으면 루프 종료
                        sb.append(line+"\n");
                    }
                    br.close(); // 버퍼 닫기
                }
                conn.disconnect(); // 연결 종료
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        // 가져와 append한 내용을 리턴
        return sb.toString();
    }
}


[    스프링에서 구글맵 연동하기    ]



스프링에서 구글맵을 연동하는 방법을 알아봅시다.

1. 먼저 구글 API 키를 다음 url로 들어가 얻습니다.

https://console.developers.google.com/flows/enableapi?apiid=maps_backend,geocoding_backend,directions_backend,distance_matrix_backend,elevation_backend,places_backend&reusekey=true


생성된 키는 곧 사용되니 잘 보관하고 계시고요


2. 두번째로 뷰단에서 html 코드를 작성합니다.

<div id="map"></div> 이 부분이 구글맵이 들어갈 자리고, style안에 #map 부분은 구글맵 컨테이너에 대한 스타일을 지정하는 부분입니다.


1
2
3
4
5
6
7
8
9
10
<style>
#map {
        width: 100%;
        height: 400px;
        background-color: grey;
      }
 </style>
        <!-- GoogleMap API 연동(황영롱) -->
    <h3>글쓴이 위치</h3>
        <div id="map"></div> <!-- 지도가 붙을 위치 -->



3. script 쪽을 작성합시다.

getAddr() 함수 부분은 제 코드임으로 구글맵과는 연관성이 없음으로 빼주시면 되고요


function initMap() 부분에서 지도의 초기화화 그려주는 역할을 하게 됩니다.

<script async defer  src="https://maps.googleapis.com/maps/api/js?key=자신의API키를넣으세요&callback=initMap">

</script>

부분은 자신의 API키를 넣어서 map을 로딩하는 요청을 보낼 수 있도록하고, 해당 로딩이 완료되면 callback에 지정한 

initMap() 메서드로 콜백이 들어와 지도를 그려주게 됩니다.


나머지 마크에 대한 설정, 위도 경도 세팅에 대한 내용은 코드 주석을 참고하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<script>
                  var address = null;
                  function getAddr(){
                      $.ajax({
                            type:'post',
                            headers:{
                                "Content-Type":"application/json"
                            },
                            async : false, // ajax를 동기화(순서대로) 처리해야하는 경우 true로하거나 기술하지 않으면 비동기로 작동한다.
                            url:"/board/category/getAddr?userId=${boardDTO.userId}",
                            dataType:"text",
                            success : function(result){
                                if ( result != null ){
                                    console.log("넘어온 값 : " + result);
                                    address = result;  
                                }
                            }
                        });
                  };
                  function initMap() { // 지도 요청시 callback으로 호출될 메서드 부분으로 지도를 맨처음 초기화하고, 표시해주는 함수
                    getAddr();
                    var latVal = ${boardDTO.lat}; // 게시글 DTO에서 위도값을 가져옴
                    var lngVal = ${boardDTO.lon}; // 게시글 DTO에서 경도값을 가져옴
                    var mapLocation = {lat: latVal, lng: lngVal}; // 위도, 경도를 가지는 객체를 생성
                /*     var map = new google.maps.Map(document.getElementById('map'), { // 위의 div id="map" 부분에 지도를 추가하는 부분
                      zoom: 18, // 확대 정도(ZOOM)
                      center: uluru // 지도에 표시해주는 중심이 우리가 만든 객체의 위치를 지정해주도록 함
                    });
                     */
                    var mapOptions = {
                            center: mapLocation, // 지도에서 가운데로 위치할 위도와 경도(변수)
                            zoom: 18, // 지도 zoom단계
                            mapTypeId: google.maps.MapTypeId.ROADMAP
                          };
                          var map = new google.maps.Map(document.getElementById("map"), // id: map-canvas, body에 있는 div태그의 id와 같아야 함
                              mapOptions);
                            
                          var size_x = 60; // 마커로 사용할 이미지의 가로 크기
                          var size_y = 60; // 마커로 사용할 이미지의 세로 크기
                            
                          // 마커로 사용할 이미지 주소
                          var image = new google.maps.MarkerImage( 'http://www.weicherthallmark.com/wp-content/themes/realty/lib/images/map-marker/map-marker-gold-fat.png',
                                              new google.maps.Size(size_x, size_y),
                                              '',
                                              '',
                                              new google.maps.Size(size_x, size_y));
                            
                          var marker;
                          marker = new google.maps.Marker({
                                 position: mapLocation, // 마커가 위치할 위도와 경도(변수)
                                 map: map,
                                 icon: image, // 마커로 사용할 이미지(변수)
                                 title: "${boardDTO.userId}(님) 의 거래 희망 위치" // 마커에 마우스 포인트를 갖다댔을 때 뜨는 타이틀
                          });
                            
                          var content = "${boardDTO.userId} 님은 "+address+" 근처에서 거래를 희망합니다."; // 말풍선 안에 들어갈 내용
                            
                          // 마커를 클릭했을 때의 이벤트. 말풍선 뿅~
                          var infowindow = new google.maps.InfoWindow({ content: content});
                    
                          google.maps.event.addListener(marker, "click", function() {
                              infowindow.open(map,marker);
                          });
                            
                    
                    /*
                     단순한 마커로 default로 표시할 때 쓰는 마커 세팅
                    var marker = new google.maps.Marker({
                        position: uluru,
                        map: map
                      });
                     
                     */
                  }
                </script>
                <!--
                    아래는 서버로부터 지도를 로딩하기 위해 요청하는 경로 async는 비동기로 로딩하도록해 지도 로딩 중 다른 웹 부분들이 열릴 수 있도록하기 위함
                    key부분에는 자신의 키를 넣고, 로딩이 완료되면 callback에 지정한 함수를 수행하게 됨.
                 -->
               <script async defer
                                src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap">
                           </script>
                <!-- End of GoogleMap API 연동(황영롱) -->


[    AJAX 통신시 컨트롤러에서 한글 문자열을 리턴해야할 경우 인코딩 문제    ]



자바스크립트에서 비동기로 ajax로 컨트롤러로 요청을 받아 db에서 값을 꺼내 문자열을 리턴할 경우, 한글 문자열을 리턴했을 때

ajax의 success:function(result) 안에서 result로 값을 받으면 ???? 로 한글이 깨지는 경우가 있다.

이럴 경우에는 컨트롤러에서 produces 부분을 지정해주면 된다.


코드를 보면


먼저, ajax 통신 부분이다. /board/category/getAddr로 요청을 보내고 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
            var address = null;
function getAddr(){
    $.ajax({
          type:'post',
          headers:{
              "Content-Type":"application/json"
          },
          async : false, // ajax를 동기화(순서대로) 처리해야하는 경우 true로하거나 기술하지 않으면 비동기로 작동한다.
          url:"/board/category/getAddr?userId=${boardDTO.userId}",
          dataType:"text",
          success : function(result){
              if ( result != null ){
                  console.log("넘어온 값 : " + result);
                  address = result;  
              }
          }
      });
};



컨트롤러 부분이다.
컨트롤러에서 service.getAddr(userId)로 해당 사용자의 주소를 얻어온 뒤 그 주소를 반환하지만 한글로 반환하게 된다.
이 경우, ajax의 success:function(result) 에서 result 가 ????로 깨지는 것을 알 수 있다.

하지만, produces = "application/text;charset=utf8"을 지정해 줄 경우 한글을 인코딩해 보내서 깨지지 않고 처리할 수 있게 된다.


1
2
3
4
5
6
7
8
// 한글을 넘기기 때문에 produces 를 기술해서 인코딩을 해서 넘겨주었다. 기술안하면 ajax에서 받았을 때 ???로 깨짐!
    @RequestMapping(value="/board/category/getAddr",method=RequestMethod.POST,produces = "application/text; charset=utf8")
    public @ResponseBody String getAddr(String userId){
        logger.info("주소얻기로 넘어온 아이디 : " + userId);
        String address = service.getAddr(userId);
        logger.info("얻어온 주소 : " + address);
        return address;
    }




[    AJAX를 동기화, 비동기화 설정해서 처리하기    ]



: JAVASCRIPT AJAX를 사용할 때, 일반적인 경우 비동기로 통신을 하게 된다. 즉, 순서대로 처리되는 것이 아니라

 처리 순서를 보장할 수가 없다. 따라서 꼭 순서를 지켜서 처리해야하는 경우는 동기화 방식으로 처리하도록 설정해주어야 한다.

 그럼 어떻게 해야할까?


 우선, ajax 내에서 async : false, 로 지정할 경우 동기화 방식으로 보내고 true로 하거나 생략할 경우 비동기방식으로 통신이 이루어지  게 된다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
            var address = null;
function getAddr(){
    $.ajax({
          type:'post',
          headers:{
              "Content-Type":"application/json"
          },
          async : false, // ajax를 동기화(순서대로) 처리해야하는 경우 true로하거나 기술하지 않으면 비동기로 작동한다.
          url:"/board/category/getAddr?userId=${boardDTO.userId}",
          dataType:"text",
          success : function(result){
              if ( result != null ){
                  console.log("넘어온 값 : " + result);
                  address = result;  
              }
          }
      });
};


[    HandleBars 적용하기    ]

: HandleBars는 Javascript 라이브러리 중 하나로, 보통 AJAX로 가져온 데이터를 JQuery에서 문자열로 조합한다음에 append해주는

  불편함을 줄여주기 위해 주로 사용됩니다.

  특징)

1. 태그를 이용해서 구성을 잡아준다.(템플릿을 만든다.)
2. 템플릿 사이사이에 데이터가 들어갈 곳에 {{ 변수 }} 로 넣어놓는다.
3. 템플릿과 데이터를 연결시 {{}} 부분에 데이터가 들어가게 된다.

이때, {{#변수}} {{/변수}} 부분에는 배열과 같은 타입의 데이터의 길이가 들어와 그 길이만큼 반복작업을 수행하게 된다.

제일먼저 사용하기 위해, HandleBars js 라이브러리를 다운로드하고, 

<script src="" 를 이용해서 임포트 해주고 사용해야 한다. 이후 설명은 아래 소스코드에서 이어가겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<%@ page language="java" contentType="text/html; charset=EUC-KR"
    pageEncoding="EUC-KR"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
 
<head>
<meta http-equiv="Content-Type" content="text/html; charset=EUC-KR">
<!--
handlebars js 라이브러리 추가
 -->
<script type="text/javascript" src="/resources/js/handlebars-v4.0.10.js"></script>
<title>Insert title here</title>
</head>
 
    <!--
        handlebars 템플릿 작성
        : script내부에 type을 text/x-handlebars-X 식으로 작성한다. X부분에는 자신이 원하는 명칭을 넣을 수 있다.
        ex) type="text/x-handlebars-myTemplate"
        handlebars의 요점)
            1. handlebars는 태그를 이용한 템플릿을 구성하고
            2. 그 템플릿에 들어갈 데이터를 구성하고
            3. 마지막으로 데이터를 템플릿에 적용한다.
            이때, 템플릿에 데이터가 들어갈 자리에는 {{ }} 이 안에 변수명을 표시해주게 된다.
             
            아래의 템플릿에서 {{#users}} {{/users}} 부분은 : users 배열과 같은 객체의 길이가 들어와 해당 길이만큼 반복문을 돌게된다.
            예를들어 users={{name:'abc',id:'sooingkr'},{name:'ccc',id:'ddd'}}; 라는 users 배열이 있으면 2번 돌면서 찍게된다.
             
            {{#users}} {{/users}} 내부에 {{name}}, {{id}} 부분에는 users 배열의 각 인덱스가 가리키는 변수명을 기재해주면 된다.
     -->
    <script id="entry-template" type="text/x-handlebars-template">
    <table>
        <thead>
            <th>이름</th>
            <th>아이디</th>
            <th>메일주소</th>
        </thead>
        <tbody>
            {{#users}}
            <tr>
                <td>{{name}}</td>
                <td>{{id}}</td>
             
            {{!-- 사용자 정의 헬퍼인 email에 id를 인자로 넘긴다 --}}
                <td><a href="mailto:{{email id}}">{{email id}}</a></td>
            </tr>
            {{/users}}
        </tbody>
    </table>
    </script>
    <script>
        // [    handlebars 적용하기 ]
     
        //핸들바 템플릿 가져온다.
        var source = $("#entry-template").html();
     
        //핸들바 템플릿 컴파일
        var template = Handlebars.compile(source);
     
        //핸들바 템플릿에 바인딩할 데이터
        var data = {
                users: [
                    { name: "홍길동1", id: "aaa1" },
                    { name: "홍길동2", id: "aaa2" },
                    { name: "홍길동3", id: "aaa3" },
                    { name: "홍길동4", id: "aaa4" },
                    { name: "홍길동5", id: "aaa5" }
                ]
        };
     
        //커스텀 헬퍼 등록 (id를 인자로 받아서 전체 이메일 주소를 반환)
        // 위에서 {{email id}} 에서 email부분에 id를 인자로 넘기게 되는 것!
        // 그럼 id@daum.net을 반환해서 들어가게 됨.
        Handlebars.registerHelper('email', function (id) {
          return id + "@daum.net";
        });
     
        //핸들바 템플릿에 데이터를 바인딩해서 HTML 생성
        var html = template(data);
     
        //생성된 HTML을 DOM에 주입
        $('body').append(html);
    </script>


타일즈와 유사하게 header, footer, body 등의 형식을 지원하는 sitemesh 설정 방법

 

[    SiteMesh 설정하기    ]

 

1. pom.xml에 dependency 추가

1
2
3
4
5
6
<!-- sitemesh -->
<dependency>
 <groupId>opensymphony</groupId>
 <artifactId>sitemesh</artifactId>
 <version>2.4.2</version>
</dependency>

 

2. web.xml에 한글처리 filter 위에 filter 추가

이때, 모든 경로가 siteMesh의 영향을 일단 받을 수 있도록 /*로 설정한다.

1
2
3
4
5
6
7
8
<filter>
  <filter-name>sitemesh</filter-name>
  <filter-class>com.opensymphony.module.sitemesh.filter.PageFilter</filter-class>
 </filter>
 <filter-mapping>
  <filter-name>sitemesh</filter-name>
  <url-pattern>/*</url-pattern>
 </filter-mapping>

 

3. /WEB-INF 폴더 밑에 sitemesh.xml 파일을 만들고 다음 코드를 붙여넣는다.

sitemesh 라이브러리를 추가했다면, WEB-INF 폴더 밑에 sitemesh.xml 파일을 자동으로 인식한다.

이 곳에서 sitemesh 설정파일의 위치를 등록해준다.

여기서는 /WEB-INF/decorators.xml 로 sitemesh의 설정파일을 사용하겠다는 것을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="UTF-8"?>
<sitemesh>
 <property name="decorators-file" value="/WEB-INF/decorators.xml"/> // sitemesh 의 환경설정 파일을 이 걸로 사용하겠다는 지정
 <excludes file="${decorators-file}"/>
 
 <page-parsers>
  <parser content-type="text/html" class="com.opensymphony.module.sitemesh.parser.FastPageParser"/>
 </page-parsers>
 
 <decorator-mappers>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.PageDecoratorMapper">
   <param name="property.1" value="meta.decorator"/>
   <param name="property.2" value="decorator"/>
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.FrameSetDecoratorMapper">
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.AgentDecoratorMapper">
   <param name="match.MSIE" value="ie"/>
   <param name="match.Mozilla" value="ns"/>
   <param name="match.Opera" value="opera"/>
   <param name="match.Lynx" value="lynx"/>
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.PrintableDecoratorMapper">
   <param name="decorator" value="printable"/>
   <param name="parameter.name" value="printable"/>
   <param name="parameter.value" value="true"/>
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.RobotDecoratorMapper">
   <param name="decorator" value="robot"/>
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.ParameterDecoratorMapper">
   <param name="decorator.parameter" value="decorator"/>
   <param name="parameter.name" value="confirm"/>
   <param name="parameter.value" value="true"/>
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.FileDecoratorMapper">
  </mapper>
 
  <mapper class="com.opensymphony.module.sitemesh.mapper.ConfigDecoratorMapper">
   <param name="config" value="${decorators-file}"/>
  </mapper>
 
 </decorator-mappers>
 
</sitemesh>

 

4. 위에서 설정파일로 사용할 xml 파일을 WEB-INF 폴더 하위에 decorators.xml로 만들고 다음 코드를 붙여넣는다.

/WEB-INF/decorators.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?xml version="1.0" encoding="utf-8"?>
 
<!-- 데코레이터 기본 폴더 -->
 
<decorators defaultdir="/WEB-INF/views/layout"> // 데코레이터 기본 폴더로 sitemesh의 layout 관련 파일은 /WEB-INF/view/layout 폴더로 추가 할 것이다.
 
 
 
 <!-- 예외 URL 패턴 -->
//여기 패턴은 sitemesh를 사용하지 않을 URL 패턴을 입력하면 된다.
//아직 만들어진 것이 없어 추가할 내용은 없지만, 추후 login 문자열이 들어간 URL은 sitemesh를 사용하지 않겠다는 것이다.
 
 <excludes>
     <pattern>*login*</pattern>
 </excludes>
 
  
// 이 부분이 데코레이터를 정의하는 부분이다.
//여기서는 아래와 같이 header와 footer가 고정으로 들어가고 본문내용만 바뀌는 데코레이터를 만들 생각이다.
// header
// body
// footer
 <decorator name="header-footer-layout" page="header-footer-layout.jsp">
  <pattern>*</pattern>
 </decorator>
 
 
</decorators>

 

5. 데코레이터 기본 폴더에 decorator로 등록한 파일을 생성하자

/WEB-INF/views/layout/header-footer-layout.jsp 를 만들고 다음 코드를 붙여넣자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<%@ page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
 
<%@ taglib prefix="decorator" uri="http://www.opensymphony.com/sitemesh/decorator" %>
 
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 
<head>
 <title>테스트 페이지</title>
  
 <!-- 공통 CSS -->
 
  
 
 <!-- 공통 JS -->
  
 <decorator:head />
</head>
 
  
 <div>공통 헤더</div>
 <hr />
  
 <div>
  <decorator:body />
 </div>
  
 <hr />
 <div>공통 푸터</div>

 

6. 그 외의 설정

여기서는 4번에서 header-footer-layout.jsp 하나만을 등록하고 *(모든 요청)시 작동하도록 하였는데,

그 외에 다른 적용이 필요한 경우 이 부분에 다른 layout 설정 파일을 등록해서 사용할 수 있다.

 

그 외에도, layout 파일 내에도 별도의 header.jsp 등을 만든 것을

<c:import 태그를 이용해서 등록해서 사용할 수 있다.

1
 
 

안녕하세요. 지금까지는 게시글 목록 페이지에서 전체 게시글 1000개가 있으면 한 페이지 내에 1000개가 다 출력되고 있는데요.

이제 게시글 목록 페이지에서 [이전][1][2][3]....[10][다음] 과 같은 페이지 처리 부분을 넣어 3 을 클릭하면 3페이지 게시글 목록을

보여주고, 또 옆에 select 박스로 한 페이지당 출력할 게시글 수를 선택할 수 있게 만들어 사용자가 현재 페이지당 출력할 게시글

수도 선택할 수 있게끔 만들어 보도록 하겠습니다.


[    1. 페이징 처리 구현    ]

페이징 처리를 할 때 가장 필요한게 무엇일까요? 저는 페이지를 처리하기 위한 VO 클래스가 필요하다 생각합니다. 그래서 가장 먼저 할 작업은


1) Criteria VO class와 PageMaker VO 클래스 두개를 만들겠습니다.

- Criteria 클래스 : 

: - page : 현재 페이지를 나타내는 필드 변수

 - perPageNum : 페이지당 표시할 게시글의 수

  - getPageStart() : limit #{pageStart}, #{perPageNum} 부분에서 pageStart를 위한 부분

두 필드 변수를 담도록 할 것이고 이때 좀 중요한 부분이 있는데 Mybatis Mapper.xml 부분에서 

게시글 목록을 검색할 때, 페이지에 맞는 게시글을 뽑아와야 합니다. 이때

select * from boardTable order by bno desc limit #{pagStart}, #{perPageNum} 

이런식으로 검색을 해야합니다. 해석해보자면 boardTable에서 bno(게시글)을 내림차순으로 정렬해줍니다. 최신글이 가장 

위로 올라와야하기 때문이죠. 그 다음 limit 부분을 보면 limit 시작 출력 행, 몇개씩 으로 ~부터 n개를 출력하겠다는 부분입니다.

이때, Mapper.xml에서 #{ } 이 안에 들어 있는 변수명은 getter를 호출해서 대입받는 것을 아실겁니다. 따라서

Criteria 클래스에선 getPageStart() 메서드가 추가로 반드시 필요합니다.!!!

이때, getPageStart()를 보면 return (page-1)*perPageNum을 볼 수 있을 것인데 

1페이지일 때 -> 0 ~ 9 게시글

2페이지 ->      10 ~ 19 

3페이지 ->      20 ~ 29

등을 적어놓고 생각해보면 왜 저런 식이 나왔는지 알 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
 *  Criteria 클래스
 *      : 페이징 처리를 위해서 사용될 객체로, 페이지 번호와, 페이지당 출력할 개시글 수를 관리할 객체이다.
 */
package org.zerock.domain;
 
public class Criteria {
    private int page; // 보여줄 페이지
    private int perPageNum; // 페이지당 보여줄 페이지수
 
    // limit 구문에서 시작 부분에 필요한 값을 반환(mybatis에서 사용)
    public int getPageStart(){
        return (this.page -1) * this.perPageNum;
    }
     
    public Criteria() { // 최초 default 생성자로 기본 객체 생성시 초기값을 지정한다. (1페이지, 10개씩)
        this.page = 1// 사용자가 세팅하지 않으면 기본 1
        this.perPageNum = 10; // 페이지당 기본 10개씩 출력하도록 세팅
    }
     
 
    // getter setter
    public int getPage() {
        return page;
    }
    public void setPage(int page) {
        if ( page <= 0 ){
            // 페이지는 1페이지부터임으로 0보다 작거나 같은값일 경우 무조건 첫번째 페이지로 설정되도록 해준다.
            this.page = 1;
        }else{
            this.page = page;
        }
    }
    public int getPerPageNum() {
         
        return perPageNum;
    }
    public void setPerPageNum(int perPageNum) {
        if ( perPageNum <= 0 || perPageNum > 100 ) {
            this.perPageNum = 10;
        }else {
            this.perPageNum = perPageNum;
        }
    }
 
    @Override
    public String toString() {
        return "Criteria [page=" + page + ", perPageNum=" + perPageNum + "]";
    }
     
}


- PageMaker 클래스

- Criteria cri : 위에서 만든 page와 perPageNum을 가지고 있는 객체

- int totalCount : 전체 게시글 수로 Mapper.xml을 통해 DB에서 전체 게시글 수를 가져와야한다.(가장 중요)
why? 전체 게시글 수가 있어야 이를 기반으로 모든 다른 "페이지"들에 대한 계산이 가능하기 때문.
- int startPage : 현재 페이지 기준에서 시작 페이지 : 현재 페이지가 12쪽이면 11쪽이 startPage일 것이고
현재 페이지가 27페이지면 21페이지가 startPage 이겠죠?
- int endPage : 현재 페이지를 기준으로 한 마지막 페이지

[11][12][13].......[20] : 현제 페이지가 13일 때 startPage는 11, endPage는 20

- boolean prev : [이전]으로 이동하는 버튼이 생길 조건이 되는지를 나타내는 boolean 변수입니다.
- boolean next : [다음]으로 이동하는 버튼이 생길 조건이 되는지를 나타내는 boolean 변수입니다.

--> 특히, 이 PageMaker 클래스는 view 단에서 페이징 처리를 하기 위해 필히 사용되어야 하는 아주 중요한 클래스입니다.
한번 잘 작성해 놓으면 가져다 쓰기 좋겠죠?


이때, PageMaker 클래스에서 주의해야할 점은

다른 변수들을 계산되려면 totalCount(전체 게시글 수)가 먼저 세팅되어야 하는 점입니다. 또한, page와 perPageNum을 이용하려면 Criteria도 먼저 세팅이 되어야 합니다.

따라서, PageMaker 객체를 사용할 때에는 setCriteria()와 setTotalCount()를 먼저 호출해서 값을 지정할 수 있도록 해주어야 한다는 점과, setTotalCount()에서 다른 변수들에 대한 계산을 해야한다는 점을 기억해야만 합니다.

아래 코드를 추가합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/**
 *  페이지 처리에 필요한 것들을 처리하는 기능을 모듈화시키기 위해 만든 클래스입니다.
 *
 */
package org.zerock.domain;
 
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
 
public class PageMaker {
 
    private Criteria cri; // page, perPageNum 을 가지고 있음
 
    private int totalCount; // 전체 게시글 수
    private int startPage; // 게시글 번호에 따른 (보여지는)페이지의 시작 번호
    private int endPage; // 게시글 번호에 따른 (보여지는)페이지의 마지막 번호
    private boolean prev; // 이전 버튼을 누를 수 있는 경우/없는 경우 분류를 위함
    private boolean next;
 
    private int displayPageNum = 10; // 화면 하단에 보여지는 페이지의 개수
    private int tempEndPage;
 
    ///////////////////////////////////////////////////////////////////////////////////////////////
    public void setTotalCount(int totalCount) {
        this.totalCount = totalCount;
 
        calcData(); // 전체 필드 변수들 세팅 : 전체 게시글 수의 setter가 호출될 때 전체 세팅되도록 함
    }
 
    private void calcData() { // 전체 필드 변수 값들을 계산하는 메서드
 
        endPage = (int) (Math.ceil(cri.getPage() / (double) displayPageNum) * displayPageNum);
 
        startPage = (endPage - displayPageNum) + 1;
 
        int tempEndPage = (int) (Math.ceil(totalCount / (double) cri.getPerPageNum()));
        this.tempEndPage = tempEndPage;
 
        if (endPage > tempEndPage) {
            endPage = tempEndPage;
        }
 
        prev = startPage == 1 ? false : true; // 1페이지면 이전 누를 수 없게 false
        next = endPage * cri.getPerPageNum() >= totalCount ? false : true;
 
    }
 
    // getter setter
 
    public Criteria getCri() {
        return cri;
    }
 
    public int getTempEndPage() {
        return tempEndPage;
    }
 
    public void setCri(Criteria cri) {
        this.cri = cri;
    }
 
    public int getTotalCount() {
        return totalCount;
    }
 
    public int getStartPage() {
        return startPage;
    }
 
    public void setStartPage(int startPage) {
        this.startPage = startPage;
    }
 
    public int getEndPage() {
        return endPage;
    }
 
    public void setEndPage(int endPage) {
        this.endPage = endPage;
    }
 
    public boolean isPrev() {
        return prev;
    }
 
    public void setPrev(boolean prev) {
        this.prev = prev;
    }
 
    public boolean isNext() {
        return next;
    }
 
    public void setNext(boolean next) {
        this.next = next;
    }
 
    public int getDisplayPageNum() {
        return displayPageNum;
    }
 
    public void setDisplayPageNum(int displayPageNum) {
        this.displayPageNum = displayPageNum;
    }
 
}

각 항목들에 대한 계산은 처음 한번 이해한 뒤엔 가져다만 씁시다.. 너무 수학적이야 ㅠㅠ
그냥 상식(?)적으로... endPage는 (무조건올림)(현재 페이지 / 페이지당 보여줄 게시글 수) * 페이지당 보여줄 게시글 수 
startPage 는 마지막 페이지에서 페이지당 보여질 게시글 수를 빼면 되고

중간에 tempEndPage 부분의 경우 endPage가 현재페이지 기준의 마지막 페이지이기 때문에 전체 게시글에 대한 마지막 페이
지를 알고 있어야 boolean next에 대한 처리를 할 수 있겠죠?




2) /resources/mappers/boardMapper.xml 을 작성해봅시다.(Mybatis DB 처리)

페이징 처리를 하기 위한 DB처리는 어떤 것들이 필요할까요? 
1) 게시글 목록을 가지고 올 때, 페이지 마다 출력해야하는 BoardVO를 담은 List를 반환하는 부분(listCriteria)
2) 전체 게시글이 몇개인지를 가져오는 부분(getTotalCount)
이 두개가 필요할 겁니다.

아래 코드를 작성합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 페이징 처리 -->
    <!-- 이때 중요한 점! 띄워줄 시작 페이지 번호는 현재 페이지인 page 변수가 오는게 아니라, (page-1) * perPageNum
        값이 와야한다.!! 헌데, #{pageStart} 는 받은 객체의 get 메서드를 호출하기 때문에 getPageStart() 메서드를
        호출하게 되고, Criteria 클래스의 getPageStart에서 위의 계산식을 반환하도록 해났음으로 시작 페이지 튜플의 행번호를
        가져올 수 있다. -->
    <select id="listCriteria" resultType="BoardVO"> <!-- typeAlias를 지정했음으로 풀 네임 기재 필요 없음 -->
        <![CDATA[
            select bno,title,content,writer,regdate,viewcnt from tbl_board where bno > 0 order by bno desc, regdate desc limit #{pageStart}, #{perPageNum}
        ]]>
    </select>
 
    <!-- 전체 게시글 수를 구하는 sql문 -->
    <select id="getTotalCount" resultType="java.lang.Integer">
        select count(*) from
        tbl_board
    </select>

위에서 다 설명했던대로 limit 부분이 보일 것입니다. 최신글을 뽑아내기 위해 order by bno desc와 
페이지에 맞는 게시글을 뽑기 위해

limit 시작 행, 몇개 를 이용해서 BoardVO를 담고 있는 List를 반환하도록 했습니다.

혹시나해서 쓰는 것이지만, 이때 <select 안에서 resultType="BoardVO" 이 부분은 본래 풀 페키지 경로를 포함한 클래스
명으로 써야하지만, 저처럼 클래스 명으로만 쓰고 싶은 경우에는

Mybatis 설정시 <typeAlias>에 default 패키지를 지정해야 한다는 점.... 알고 계시겠죠??
혹시 모르신다면... Mybatis를 한번 더 살펴보시거나, 그냥 resultType="패키지.BoardVO" 로 하시면 됩니다.


3) DAO 작업과 Service 작업

이제 익숙해졌으리라 생각합니다. Mapper.xml에 추가했음으로 당연히 DAO와 Service 객체에도 추가해야겠죠.

- BoardDAO 인터페이스
- BoardDAOImpl 클래스
- BoardService 인터페이스
- BoardServiceImpl 클래스
를 작성합시다.

먼저 BoardDAO

여러분, 정말 혹시나!해서... 페이징 처리를 위한 메서드 2개(listCriteria()와 TotalCount()) 만 추가하시는 것 알죠?
나머지 부분들은 게시글 추가,삭제, 수정 등에 대한 메서드에요 여러분 것과 제가 많이 다를 수 있으니
그 부분은 제껄 그대로 추가하시면.... 오류생깁니다...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package org.zerock.persistence;
 
import java.util.List;
 
import org.zerock.domain.BoardVO;
import org.zerock.domain.Criteria;
import org.zerock.domain.SearchCriteria;
 
public interface BoardDAO {
 
    // [ 페이징 처리를 위한 메서드 ]
    public List<BoardVO> listCriteria(Criteria cri) throws Exception;
 
    // 전체 게시글 수 구하기
    public Integer TotalCount() throws Exception;
 
    public List<BoardVO> listSearch(SearchCriteria cri) throws Exception;
////////////////////////////////////////////////////////////////////////////
    // Test 코드를 위한 현재 시간 가져오기 메서드
    public void readTime();
 
    // 게시글 생성<등록>(insert)
    public void create(BoardVO vo) throws Exception;
 
    // 게시글 자세히 읽기
    public BoardVO read(Integer bno) throws Exception; // 게시글 번호를 받아 DB 작업을 통해
                                                        // 현재 게시판 정보를 DTO에 담아 반환
 
    // 게시글 수정
    public void update(BoardVO vo) throws Exception;
 
    // 게시글 삭제
    public void delete(Integer bno) throws Exception;
 
    // 게시글 전체 목록 보기
    public List<BoardVO> listAll() throws Exception;
 
    // 글 클릭시, 조회수 증가
    public void updateViewCnt(Integer bno) throws Exception;
 
    public int listSearchCount(SearchCriteria cri) throws Exception;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package org.zerock.persistence;
 
import java.util.List;
 
import javax.inject.Inject;
 
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;
import org.zerock.domain.BoardVO;
import org.zerock.domain.Criteria;
import org.zerock.domain.SearchCriteria;
 
// @Repository : 자동 객체 생성 특히, dao를 지칭하는 말이기도함(스프링이 알아서 관리해주게됨) : 이게 자동으로 생성되려면 설정파일에서 <context:component-scan 지정필요
@Repository
public class BoardDAOImpl implements BoardDAO {
 
    // 설정파일에서 생성한 sqlSessionTemplate 객체를 자동 주입한다.(Autowired, Resource 와 유사함)
    @Inject
    SqlSession sqlSession;
    // 반복적으로 사용될 것임으로 상수로 빼놈
    final static String NAMESPACE = "org.zerock.mapper.BoardMapper";
     
    @Override
    public List<BoardVO> listCriteria(Criteria cri) throws Exception {
        return sqlSession.selectList(NAMESPACE + ".listCriteria",cri);
    }
 
    @Override
    public Integer TotalCount() throws Exception {
        return sqlSession.selectOne(NAMESPACE + ".getTotalCount");
    }
     
     
    // Test 코드를 위한 현재 시간 가져오기 메서드
    @Override
    public void readTime() {
        // namespace명.mapper에서 지정한 id값
        String time = sqlSession.selectOne(NAMESPACE + ".getTime");
        System.out.println("현재시간 : " + time);
    }
 
    @Override
    public void create(BoardVO vo) throws Exception {
        sqlSession.insert(NAMESPACE + ".create",vo);
    }
 
    @Override
    public BoardVO read(Integer bno) throws Exception {
        return sqlSession.selectOne(NAMESPACE + ".read",bno);
    }
 
    @Override
    public void update(BoardVO vo) throws Exception {
        sqlSession.update(NAMESPACE + ".update", vo);
    }
 
    @Override
    public void delete(Integer bno) throws Exception {
        sqlSession.delete(NAMESPACE + ".delete",bno);
    }
 
    @Override
    public List<BoardVO> listAll() throws Exception {
        return sqlSession.selectList(NAMESPACE + ".listAll"); // vo를 담고 있는 ArrayList를 반환
    }
 
    @Override
    public void updateViewCnt(Integer bno) throws Exception {
        sqlSession.update(NAMESPACE + ".updateViewCnt", bno);
    }
 
 
    @Override
    public List<BoardVO> listSearch(SearchCriteria cri) throws Exception {
        return sqlSession.selectList(NAMESPACE + ".listSearch",cri);
    }
 
    @Override
    public int listSearchCount(SearchCriteria cri) throws Exception {
        return sqlSession.selectOne(NAMESPACE + ".listSearchCount",cri);
    }
 
}

BoardService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package org.zerock.service;
 
import java.util.List;
 
import org.zerock.domain.BoardVO;
import org.zerock.domain.Criteria;
import org.zerock.domain.SearchCriteria;
 
public interface BoardService {
 
    // 페이징 처리 서비스 메서드
    public List<BoardVO> listCriteria(Criteria cri) throws Exception;
 
    // 전체 게시글 수 구하기
    public Integer totalCount() throws Exception;
/////////////////////////////////////////////////////////////////////////////////
    // 게시글 등록 서비스
    public void regist(BoardVO board) throws Exception; // 서비스에서 예외를 던지면
                                                        // Controller가 이를 처리를
                                                        // 하도록 되겠지?
    // 그럼, 컨트롤러의 예외를 처리하는 @ControllerAdvice를 통해 모든 예외를 한번에 처리하도록 여기선 throws
    // Exception 해준다.
 
    // 게시글 읽기 서비스
    public BoardVO read(Integer bno) throws Exception;
 
    // 게시글 수정 서비스
    public void modify(BoardVO board) throws Exception;
 
    // 게시글 제거 서비스
    public void remove(Integer bno) throws Exception;
 
    // 전체 게시글 목록 조회 서비스
    public List<BoardVO> listAll() throws Exception;
 
    // 검색에 해당되는 게시글 띄우기
    public List<BoardVO> listSearchCriteria(SearchCriteria cri) throws Exception;
 
    // 검색어에 해당하는 게시글 개수 구하기
    public int listSearchCount(SearchCriteria cri) throws Exception;
}

BoardServiceImpl

다 마찬가지에요 페이징 처리 부분만 복사하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package org.zerock.service;
 
import java.util.List;
 
import javax.inject.Inject;
 
import org.springframework.stereotype.Service;
import org.zerock.domain.BoardVO;
import org.zerock.domain.Criteria;
import org.zerock.domain.SearchCriteria;
import org.zerock.persistence.BoardDAOImpl;
 
// 해당 서비스 객체가 자동으로 생성될 수 있도록 @Service를 붙인다. @Repository, @Component 등과 마찬가지로 root-context.xml에 context:component-scan을 지정해 주자.
@Service // 컨트롤러에서 주입해서 쓰기 위함
public class BoardServiceImpl implements BoardService {
 
    // 자동주입 : @Repository를 써서 자동 생성해논걸 DI
    @Inject
    BoardDAOImpl dao;
 
///////////////[페이징 처리 부분]////////////////////////
    @Override
    public List<BoardVO> listCriteria(Criteria cri) throws Exception {
        return dao.listCriteria(cri);
    }
 
    @Override
    public Integer totalCount() throws Exception {
        return dao.TotalCount();
    }
////////////////////////////////////////////////////
    @Override
    public void regist(BoardVO board) throws Exception {
        dao.create(board);
    }
 
    @Override
    public BoardVO read(Integer bno) throws Exception {
        dao.updateViewCnt(bno);
        return dao.read(bno);
 
    }
 
    @Override
    public void modify(BoardVO board) throws Exception {
        dao.update(board);
    }
 
    @Override
    public void remove(Integer bno) throws Exception {
        dao.delete(bno);
    }
 
    @Override
    public List<BoardVO> listAll() throws Exception {
        return dao.listAll();
    }
 
    @Override
    public List<BoardVO> listSearchCriteria(SearchCriteria cri) throws Exception {
        return dao.listSearch(cri);
    }
 
    @Override
    public int listSearchCount(SearchCriteria cri) throws Exception {
        return dao.listSearchCount(cri);
    }
 
}


4) BoardController에서 listPage() 부분 작업을 해봅시다.

위에서 얘기한대로 컨트롤러의 게시글 목록을 보여주는 listPage() 메서드에서는 다음과 같은 작업을 해주어야 합니다.

@RequestMapping(value = "/listPage", method = RequestMethod.GET) public String listPage(@ModelAttribute("cri") Criteria cri, Model model) throws Exception {                 // 커맨드 객체로 Criteria를 매개변수로 넣어줘, 넘어오는 page와 perPageNum정보를 받습니다.

// 해당 cri 를 이용해서 service->dao->mapper.xml 순으로 접근하면서 DB처리를 해 cri 전달된

// 현재 페이지 정보를 기준으로 BoardVO 객체를 담은 ArrayList가 반환될 것입니다.

List<BoardVO> dto = service.listCriteria(cri);

// 이제 view jsp 페이지에서 페이징 처리를 위해 사용할 PageMaker 객체를 생성하고 PageMaker pageMaker = new PageMaker();

// 여기 부분을 "꼭" 해 주어야 한댔죠? Criteria를 set해주고 setTotalCount() 를 해주어야

// 페이징처리에 필요한 것들이 내부적으로 계산될 수 있도록 작성했다고 했습니다. pageMaker.setCri(cri); Integer totalNum = service.totalCount(); pageMaker.setTotalCount(totalNum);

// /views/board/listPage.jsp 에서 페이징 처리를 하기 위해 PageMaker 객체를 저장해 놓아야 할 것이고

// 당연히 화면에 게시글을 뿌려주기 위해서 꺼내온 dto도 저장을 해 주어야 할 것입니다.(model 객체에) model.addAttribute("pageMaker", pageMaker); model.addAttribute("list", dto); return "board/listPage"; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RequestMapping(value = "/listPage", method = RequestMethod.GET)
public String listPage(@ModelAttribute("cri") Criteria cri, Model model) throws Exception {
            //  커맨드 객체로 Criteria를 매개변수로 넣어줘, 넘어오는 page와 perPageNum정보를 받습니다.
 
            // 해당 cri 를 이용해서 service->dao->mapper.xml 순으로 접근하면서 DB처리를 해 cri 전달된
 
            // 현재 페이지 정보를 기준으로 BoardVO 객체를 담은 ArrayList가 반환될 것입니다.
 
    List<BoardVO> dto = service.listCriteria(cri);
     
 
            // 이제 view jsp 페이지에서 페이징 처리를 위해 사용할 PageMaker 객체를 생성하고
    PageMaker pageMaker = new PageMaker();
 
            // 여기 부분을 "꼭" 해 주어야 한댔죠? Criteria를 set해주고 setTotalCount() 를 해주어야
 
            // 페이징처리에 필요한 것들이 내부적으로 계산될 수 있도록 작성했다고 했습니다.
    pageMaker.setCri(cri);
    Integer totalNum = service.totalCount();
    pageMaker.setTotalCount(totalNum);
 
 
            // /views/board/listPage.jsp 에서 페이징 처리를 하기 위해 PageMaker 객체를 저장해 놓아야 할 것이고
 
            // 당연히 화면에 게시글을 뿌려주기 위해서 꺼내온 dto도 저장을 해 주어야 할 것입니다.(model 객체에)
    model.addAttribute("pageMaker", pageMaker);
    model.addAttribute("list", dto);
 
    return "board/listPage";
}


5) 이제 /views/board/listPage.jsp 페이지에 대한 작업과 그에 필요한 사전 작업을 해봅시다.


listPage.jsp에서 이제 페이징 처리를 할 것인데, 페이지에 따라 a태그를 이용해서 href로 페이지를 띄우는 요청을 걸어놓겠죠?

그런데, 이러한 작업이 계속 반복될 것입니다. 그러면 이걸 하드코딩하는 것 보단 메서드로 만들어 놓으면 어떨가요?

ex) <a href="/board/listPage?page=3?perPageNum=10>[3]</a> 3쪽을 누르면 이런식으로 호출되도록 링크를 걸겁니다.

그러면 page변수가 넘어가서 Controller의 listPage(Criteria cri) 해 놓은 부분에서 cri의 setPage()와 setPerPageNum()을 호출해 데이터를

넣겠죠? 이러한 부분이 계속 반복된다는 것입니다. 근대 그나마 여기서는 칠만해요... 솔직히

근대 동적 검색을 지원하는 기능까지 붙이잖아요? 변수가 늘어나고? 엄청 짜증나기 시작할거에요... 그래서 미리 만들어 놓는 것이 좋습니다.


이러한걸 Spring은 지원하는대요 그게 

"UriComponent"라는 겁니다.

<a href="/board/listPage?page=3?perPageNum=10>에서 ? 이 부분부터 "쿼리 문자열"이라고 하잖아요?

그 부분을 생성하는 메서드를 만들건대 page라는 정보와 perPageNum 정보가 필요하니까 두 변수를 가지고 있는 Criteria나 PageMaker에서

해주는게 좋겠죠? 여기서는 PageMaker 에서 해봅시다.

PageMaker class에서 makeQuery(int page) 라는 메서드를 만들어서 작성해봅시다.


1
2
3
4
5
6
7
public String makeQuery(int page) {
 
        UriComponents uriComponents = UriComponentsBuilder.newInstance().queryParam("page", page)
                .queryParam("perPageNum", cri.getPerPageNum()).build();
 
        return uriComponents.toUriString();
    }


6) /views/board/listPage.jsp 게시글 목록을 보여주는 페이지에서 작업합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<ul class="pagination">
    <!--
        이전 버튼이 클릭가능한 조건이면, a태그를 이용해 이전 버튼이 뜨도록 하고, href로 링크를 걸되
        아까 만든 makeQuery 메서드를 이용해서 쿼리문자열을 만들어 줍니다.
        ?page=어쩌고&perPageNum=어쩌고 이 부분을 생성해서 넣게 되는데 단 이전 버튼을 클릭하면
        현재 페이지가 시작페이지의 - 1 이 되도록 되어야 함으로 그 부분만 신경써 주면 됩니다.
     -->
    <c:if test="${pageMaker.prev}">
        <li>
            <a href="/board/listPage${pageMaker.makeQuery(pageMaker.startPage - 1)}">[이전]</a>
        </li>
         
    </c:if>
 
    <!--
        [1][2][3]....[10] 이 부분을 삽입하는 부분이다. jstl 이용해 for문을 돌면서 startPage ~ endPage까지
        표시해주되, a태그 눌렀을 때 이동하는 page 부분에 index 지정하는 부분을 유의깁게 보길 바란다.
     -->
    <c:forEach begin="${pageMaker.startPage }" end="${pageMaker.endPage }" var="index">
        <a href="/board/listPage${pageMaker.makeQuery(index) }">[${index }]</a>
    </c:forEach>
 
    <c:if test="${pageMaker.next }">
        <!--
            이전버튼과 마찬가지로 다음버튼을 끝 페이지보다 1개 큰게 현재 페이지가 되도록 makeQuery에 page를 넣어줍시다.
         -->
        <li>
            <a href="/board/listPage${pageMaker.makeQuery(pageMaker.endPage + 1} }">[다음]</a>
        </li>
    </c:if>  
</ul>

7) listPage.jsp 페이지에 현재 페이지에 따른 게시글을 표시하도록 합시다.

6번에 작성한 코드 바로 윗 부분에 해주어야 [이전][1][2][3]...[10][다음] 윗 부분에 들어갈 게시글 목록부분입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- 게시글 테이블 -->
<table class="table table-bordered">
    <tr>
        <th style="width: 10px">No</th>
        <th>제목</th>
        <th>작성자</th>
        <th>등록일</th>
        <th style="width: 60px">조회수</th>
    </tr>
 
    <!-- dao 작업 결과를 model에 저장시킨 걸 반복하면서 [게시글을]출력한다. -->
 
    <c:forEach items="${list}" var="boardVO">
        <tr>
            <td>${boardVO.bno}</td>
            <!-- 제목 클릭시 글 읽기로 요청(쿼리문자열로 글번호 같이 보내줌) -->
            <td><a
                href="/board/read${pageMaker.makeQuery( pageMaker.cri.page )}&bno=${boardVO.bno}">${boardVO.title}</a></td>
            <td>${boardVO.writer}</td>
            <td><fmt:formatDate pattern="yyyy-MM-dd HH:mm"
                    value="${boardVO.regdate}" /></td>
            <td align="center"><span class="badge bg-red">${boardVO.viewcnt }</span></td>
        </tr>
    </c:forEach>
 
</table>
 
<button id="register">글등록</button>

여기서 관심있게 볼 점은 게시글 목록의 경우 제목을 클릭하면, 해당 상세 읽기 페이지로 분기해야합니다.

따라서, href="/board/read${pageMaker.makeQuery(pageMaker.cri.page)}&bno=${boardVO.bno} 부분을 통해

page, perPageNum, bno(클릭했을 때 읽을 게시글 번호) 정보를 함께 넘겨 주어야 합니다.

그외에 boardVO 객체에 있는 게시글 등록 일 같은 정보를 출력하기 위해 jstl의 <fmt:formatDate 태그를 이용한 것을 알 수 

있습니다.

그리고 마지막 부분에 글을 등록할 수 있는 글등록 버튼을 하나 넣었습니다.



8) listPage.jsp에서 글 등록 버튼을 클릭했을 때 글등록 페이지로 이동하도록 JQuery를 이용해보겠습니다.


<script>

// 글 작성처리에서 글 작성 성공시 redirect 직전에 RedirectAttribute 객체의 addFlashAttribute로 저장한 msg 변수

var result = '${msg}'; // 글작성 등록에 성공했을 때 msg 지정해 논 부분


if (result == 'SUCCESS') {

alert("처리가 완료되었습니다.");

}

// 이 부분이 8번에서 핵심 부분입니다.

$(document).ready(function() {

// 글 등록 버튼을 클릭하면...

$("#register").click(function() {

self.location = "/board/register"; // 글 작성 폼 페이지로 이동한다.

});

});

</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script>
 
    // 글 작성처리에서 글 작성 성공시 redirect 직전에 RedirectAttribute 객체의 addFlashAttribute로 저장한 msg 변수
 
    var result = '${msg}'; // 글작성 등록에 성공했을 때 msg 지정해 논 부분
 
 
 
    if (result == 'SUCCESS') {
 
        alert("처리가 완료되었습니다.");
 
    }
 
// 이 부분이 8번에서 핵심 부분입니다.
 
    $(document).ready(function() {
 
        // 글 등록 버튼을 클릭하면...
 
        $("#register").click(function() {
 
            self.location = "/board/register"; // 글 작성 폼 페이지로 이동한다.
 
        });
 
    });
 
</script>


9) BoardController의 read() 부분을 수정해봅시다.


글 등록처리야 /board/register 를 호출하면서 글 등록 폼으로 잘 이동할 것이고 문제는 글 제목을 클릭했을 때 

읽는 페이지 부분입니다. 우리가 /board/read?page=3&perPageNum=10 이런식으로 전달했기 때문에

이 read 부분에서도 page와 perPageNum을 Criteria를 이용해서 받아야만 합니다.


그리고 "진짜 진짜" 중요한 점은, 이 page정보와 perPageNum 정보를 숨김값(hidden) 으로 수정페이지나 삭제 페이지에 전달

해야만 합니다. 왜그럴까요?

왜냐하면 여러분이 3페이지의 게시글을 보고 있다가 글으로고 제목을 클릭했습니다. 그런데 글을 읽고 목록으로 다시 돌아오거나

삭제 페이지 혹은 수정페이지로 이동을 했다가 목록으로 돌아왔을 때 첫페이지로 돌아와있다면 어떨까요? 불편하겠죠?(그렇다해줘요...)


80페이지까지 넘기며 보고 있었는데 게시글 한번 클릭해 보고 왔더니 1페이지... 최악이잖아요? 다시 넘기기도 귀찮고 그래서

현재 보고 있었던 page 정보는 무조건 계속 다른 곳으로 전달하면서 유지시켜주어야합니다. 그럴려면 어떻게 할까요?

<form ~~>

<input type="hidden" name="page" value="${pageMaker.cri.page}" />

</form>

이런식으로 숨김값으로해서 전달해주는 것이 필요합니다.


그래서 아래 코드에서는 

우선, 1) read메서드에서 page와 perPageNum을 전달받을 수 있도록 커맨드 객체를 추가시켜주고, readPage쪽 jsp 페이지에서

페이지에 대한 정보를 사용할 수 있도록 model 객체에 add해주어야 합니다.

  2) read.jsp 에서 hidden값으로 값이 넘어갈 수 있도록 작업도 들어가야겠내요.


BoardController의 read()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** 글 읽기(detail) */
    @RequestMapping(value = "/read", method = RequestMethod.GET)
    public String read(@RequestParam("bno") int bno, @ModelAttribute("cri") Criteria cri, Model model) {
            // page정보와 perPageNum을 받을 수 있도록 Criteria 객체를 추가해주고요
            // 게시글 bno 정보도 같이 넘어오니까 bno도 가지고 있어야겠죠
        BoardVO dto = null;
        try {
            // 받은 bno 정보를 이용해 db에 접근해서 BoardVO(게시글 정보를 담고 있는 객체)를 꺼내오고
            dto = service.read(bno);
        } catch (Exception e) {
        }
         
        // page 정보를 담고 있는 cri와 dto를 jsp 페이지에서 사용하기 위해 model에 추가해줍니다.
        // 물론 @ModelAttribute("cri") 를 한 경우 아래처럼 model에 add안해줘도 자동으로 추가됩니다.
        model.addAttribute("cri",cri);
        model.addAttribute("boardVO", dto);
        // 만약 이름을 지정하지 않으면 그 클래스명의 첫글자가 소문자인 상태로 지정된다.
        // ex) model.addAttribute(dto); => boardVO 이름으로 dto가 들어간다.
 
        // Criteria는 커맨드 객체를 이용해 주입받았음으로 model에 추가하지 않아도 자동으로 cri라는 이름으로 추가되어
        // 있음을 알 수 있다.
        return "board/read"; // 글 읽기 페이지 뷰를 띄운다.
    }


/views/board/read.jsp

1
2
3
4
5
6
7
8
9
10
<form name="form1" role="form" method="post">
    <!-- 글 세부 조회에서는 "수정","삭제","목록가기" 등을 클릭함에 따라 현제 페이지에서 글번호를 보내주어야 할 경우가 있다.
        따라서, hidden 타입으로 숨겨놓되 전달시 같이 전송될 수 있도록 hidden타입의 value로 설정해 놓는다.
     -->
    <input type='hidden' name='bno' value="${boardVO.bno}">
    <input type="hidden" name="page" value="${cri.page }" />
    <input type="hidden" name="perPageNum" value="${cri.perPageNum }" />
     
     
</form>


 + [ read.jsp 페이지에서 게시글 정보가 표시되는 부분입니다. ]


<div class="box-body">

<div class="form-group">

<label for="exampleInputEmail1">Title</label> <input type="text"

name='title' class="form-control" value="${boardVO.title}"

readonly="readonly">

</div>

<div class="form-group">

<label for="exampleInputPassword1">Content</label>

<textarea class="form-control" name="content" rows="3"

readonly="readonly">${boardVO.content}</textarea>

</div>

<div class="form-group">

<label for="exampleInputEmail1">Writer</label> <input type="text"

name="writer" class="form-control" value="${boardVO.writer}"

readonly="readonly">

</div>

</div>

<!-- /.box-body -->


<div class="box-footer">

<button type="submit" class="btn btn-warning">수정</button>

<button type="submit" class="btn btn-danger">삭제</button>

<button type="submit" class="btn btn-primary">목록으로</button>

</div>


이 부분을 바로 아래 추가하고


read.jsp 페이지에서 목록가기,수정 등 버튼 클릭에 따른 JQuery 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
$(document).ready(function(){
 
     
// form 태그를 꺼내와서
    var formObj = $("form[role='form']");
 
     
 
    console.log(formObj);
 
     
 
    // 수정 버튼 클릭시...
// 수정버튼에 click 이벤트를 주고
    $(".btn-warning").on("click", function(){ // 수정 클릭시
// 위에서 hidden값들을 넘기기 위해 만들어논 form의 속성을 변경하되 action을 수정이니까 /board/modify 로 변경하고
        formObj.attr("action", "/board/modify");
// method는 get 방식으로 바꾸고
        formObj.attr("method", "get");     
// 폼을 전송함
        formObj.submit();
 
    });
 
     
 
    // 삭제 버튼 클릭시...
 
    $(".btn-danger").on("click", function(){
 
        formObj.attr("action", "/board/remove");
 
        formObj.submit();
 
    });
 
     
 
    // 목록으로 버튼 클릭시
 
    $(".btn-primary").on("click", function(){
 
        // self.location : 현재 창 변경하는 JQuery 메서드
 
        formObj.attr("method","get");
 
        formObj.attr("action","/board/listPage?page=${cri.page}&perPageNum=${cri.perPageNum}");
 
        formObj.submit();
 
//      self.location = "/board/listPage?page=${cri.page }&perPageNum=${cri.perPageNum}";
 
    });
 
     
 
});
 
 
 
</script>

히든 값이 있는 form의 속성들을 JQuery로 변경해가며 수정, 삭제, 목록으로 가기 등에 맞춰 변경해 submit해주고 있습니다.




10) BoardController의 remove() 부분


글을 읽다가 해당 글을 삭제하기를 눌렀을 때, 위의 코드까지 진행하면 page와 perPageNum이 전달될 것입니다.
컨트롤러의 remove() 부분에서는 글을 삭제하고 삭제처리가 완료되면 목록보기로 redirect 해주어야 합니다.

이때, 중요한 점은 redirect시에는 model 객체가 전달되지 않는다는 점 알고 계시죠?

따라서, model을 사용하는 것이 아니고, RedirectAttribute 객체를 사용해야합니다. 다시 한번 말하지만 Model 객체는

redirect시에는 전달되지 않습니다.!



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** 게시글 삭제 */
    @RequestMapping(value = "/remove", method = RequestMethod.POST)
    public String remove(@RequestParam("bno") int bno, @ModelAttribute("cri") SearchCriteria cri, RedirectAttributes rttr,
            Model model) {
// redirect시에는 Model 객체가 전달되지 않기 때문에 page, perPageNum 정보를 담기 위해서 RedirectAttribute 객체를 사용합니다.
 
        try {
// db 작업을 통해 해당 게시글을 삭제작업합니다.
            service.remove(bno);
        } catch (Exception e) {
        }
 
        // 리다이렉트로 목록 페이지로 이동시 글이 삭제되었는지, 수정이 성공했는지에 따라 JQUERY로 처리하기 위해 데이터를 저장시키는 부분입니다.
// 이렇게 redirect를 해서 게시글 삭제가 완료되고 게시글 목록으로 넘어가더라도 listPage에서는 Criteria 객체에 담겨 있는 page와 perPageNum 정보를 이용해
// 삭제 직후에도 사용자가 기존에 보고 있던 페이지를 계속 보고 있을 수 있게됩니다.
        rttr.addFlashAttribute("msg", "SUCCESS");
        rttr.addFlashAttribute("cri", cri);
         
// 목록 페이지로 리다이렉트
        return "redirect:/board/listPage";
    }


11) 마지막, 수정 페이지에 대한 작업


읽기, 삭제 페이지까지의 작업을 다 했음으로 마지막으로 수정에 관한 작업만 남았습니다.

어차피 위에서 한 작업과 마찬가지로 코드만 추가하도록 하겠습니다. 수정페이지도 read 페이지처럼

hidden으로 데이터를 전송하는 부분이 있겠죠?


- BoardController의 modify 메서드 부분

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/** 게시글 수정 폼으로 이동 */
    @RequestMapping(value = "/modify", method = RequestMethod.GET)
    public String modifyGET(@RequestParam("bno") int bno, @ModelAttribute("cri") Criteria cri, Model model) {
 
        // 게시글 읽기(read).jsp 페이지에서 수정을 눌렀을 때 수정하려는 해당 게시글 번호를 hidden으로 지정해서
        // 넘겨주었다.
        // 이걸 받아와 폼으로 넘겨주어야함.
        BoardVO dto = null;
        try {
            dto = service.read(bno);
        } catch (Exception e) {
        }
 
        // 해당 bno에 해당하는 게시글 정보를 가지고 수정 폼에서 미리 세팅되어 있어야함
        model.addAttribute("boardVO", dto);
        // read폼에서 hidden으로 보낸 page와 perPageNum값은 커맨드 객체인 Criteria 객체에 담겨 모델에
        // cri라는 이름으로 저장된 상태로 modify폼으로 전달됨
 
        return "board/modify"; // 수정 폼으로 이동
    }
 
    /** 게시글 수정 실제 처리(DB) */
    @RequestMapping(value = "/modify", method = RequestMethod.POST)
    public String modifyPOST(BoardVO board, Criteria cri, BindingResult errors, Model model, RedirectAttributes rttr) {
 
        BoardValidation validator = new BoardValidation();
        validator.validate(board, errors);
 
        if (errors.hasErrors()) {
            return "redirect:/board/modify";
        } else { // 폼값에 오류가 없다면...
 
            try {
                service.modify(board); // db에 수정 작업하도록 서비스 클래스 수행
            } catch (Exception e) {
            }
            rttr.addFlashAttribute("msg", "SUCCESS"); // 수정 성공시 메시지 등록해서 리다이렉트
                                                        // 시킴
            // 목록 페이지에서는 if ( '${msg}' == "SUCCESS" ) { alert("작업이 성공했습니다.") }
            rttr.addFlashAttribute("cri", cri);
            return "redirect:/board/listPage"; // 수정작업 완료 후 목록 페이지로 다시 이동.
        }
 
    }


- /views/board/modify.jsp 페이지 작업


<form role="form" method="post" action="/board/modify"> <!-- action이 없음으로 이 페이지 요청될 때의 url과 동일한 url로 요청하되 post방식으로 됨. -->


<div class="box-body">


<div class="form-group">

<label for="exampleInputEmail1">BNO</label> <input type="text"

name='bno' class="form-control" value="${boardVO.bno}"

readonly="readonly"> <!-- 게시글 번호는 수정하지 못하도록 readonly 로 설정 -->

</div>


<div class="form-group">

<label for="exampleInputEmail1">Title</label> <input type="text"

name='title' class="form-control" value="${boardVO.title}"> <!-- 읽어온 게시글 값들을 미리 세팅해 놔야함 -->

</div>

<div class="form-group">

<label for="exampleInputPassword1">Content</label>

<textarea class="form-control" name="content" rows="3">${boardVO.content}</textarea>

</div>

<div class="form-group">

<label for="exampleInputEmail1">Writer</label> 

<input type="text" name="writer" class="form-control" value="${boardVO.writer}">

</div>

<!-- 숨김값으로 보내줘야 할 값들 -->

<input type="hidden" name="page" value="${cri.page }" />

<input type="hidden" name="perPageNum" value="${cri.perPageNum }" />

</div>

<!-- /.box-body -->

</form>



<div class="box-footer">

<button type="submit" class="btn btn-primary">수정</button>

<button type="submit" class="btn btn-warning">취소</button>

</div>


<script>

$(document).ready(function() {


var formObj = $("form[role='form']");


console.log(formObj);

// 취소 클릭시 다시 리스트 목록페이지로 돌아가도록 함

$(".btn-warning").on("click", function() {

// self.location : 현재 페이지를 다른 페이지로 전환할 떄 사용하는 JQuery 메서드

self.location = "/board/listPage?page=${cri.page}&perPageNum=${cri.perPageNum}";

});

// 수정 버튼 클릭시 폼 제출되도록 함

$(".btn-primary").on("click", function() {

formObj.submit();

});


});

</script>






[ 스프링 환경 설정 정리 ]


1. [ index.jsp 메인 화면 띄우기 ]


1) WEB-INF/views 폴더 아래에 메인페이지로 쓸 index.jsp 페이지를 생성한다.

2) root 패키지에서 controller 패키지를 추가한 뒤, MyController.java 처럼 앞으로 컨트롤러로 이용할 자바 클래스를 만들고 아래처럼 소스코드를 작성한다.


@Controller // 스프링 웹 설정파일인 WEB-INF/spring/appServlet/servlet-context.xml 에서 <contextcomponent scan package로 지정된 패키지에서 탐색을 해 @Controller가 있는 클래스를 컨트롤러 클래스로 인식한다.

public class MyController {


// 메인 페이지 임으로 http://127.0.0.1:80/ 으로 요청시 index.jsp가 뜰 수 있도록 RequestMapping을 / 로 지정한다.

@RequestMapping(value="/")

public String homePage(){

return "index"; // servlet-context.xml 파일에서 ViewResolver에 매칭되는 부분으로 띄워줄 뷰 이름을 적는다.

}


3) 여기까지 작업시, http://localhost:80/root패키지/ 이렇게 해야 index.jsp가 뜨게 됨으로 http://localhost:80/ 만으로 뜨게하기 위해서 servers 탭의 서버를 더블클릭해 서버 설정창으로 들어가 modules 탭을 누르고 Edit를 눌러 Path 에 / 가 되도록 수정해 준다.


4) web.xml에 들어가 <welcome-file-list>index.jsp</welcome-file-list>를 추가해준다.

5) 앞으로 우리가만든 MyController로 쓸 것임으로 기존에 있던 HomeController 지워버리자.


: 결과) http://localhost:80/ 만으로 index.jsp 페이지가 뜨게 된다.

-----------------------------------------------------------


2. [ 한글 설정하기   ]


: JSP에서 filter를 이용해 controller로 들어가기 전에 request.setCharacterEncoding("UTF-8")을 해줬던 것처럼 스프링에선 이러한 역할을 하는 클래스를 제공한다. 이 클래스를 filter에 등록하고 url 매핑을 해주자.


1) web.xml에

<!-- [한글처리] -->

<filter>

<filter-name>encodingFilter</filter-name>

<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>

<init-param>

<param-name>encoding</param-name>

<param-value>UTF-8</param-value>

</init-param>

</filter>

<filter-mapping>

<filter-name>encodingFilter</filter-name>

<url-pattern>/*</url-pattern>

</filter-mapping>


코드를 추가해주자. 단! 주의할 점은, 반드시 servlet 태그 위에 위치해야한다.!! 서블릿보다 필터가 먼저 와야함!!

-----------------------------------------------------------

3. [ DB 설정하기 ]


1) [ DB DataSource 객체 생성 및 JUnit 단위 테스트하기]

a) pom.xml에 아래 라이브러리를 추가시킨다.


<!-- mysql -->

<dependency>

<groupId>mysql</groupId>

<artifactId>mysql-connector-java</artifactId>

<version>5.1.35</version>

</dependency>


<!-- spring-jdbc : datasource 객체 가져올 때 쓰임 -->

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-jdbc</artifactId>

<version>4.3.7.RELEASE</version>

</dependency>


<!-- spring-test : Junit을 이용해 스프링 코드를 테스트할 때 필요하다. -->

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-test</artifactId>

<version>4.3.7.RELEASE</version>

</dependency>


+ JUnit 버전을 4.10 이상으로 올려준다.!!

b) 스프링 환경설정 파일에 datasource 빈 객체를 등록해주어야 하는데 스프링에는 환경설정 파일이 두개가 있다.

- WEB-INF/spring/appServlet/servlet-context.xml

: 웹과 관련된 환경설정을 담당한다.

- WEB-INF/spring/root-context.xml

: 웹이외의 환경설정을 담당

ex) DB설정, 트랜잭션 설정 등등...

따라서, root-context.xml에 아래의 코드를 추가시킨다.

<!-- [데이터베이스 DataSource설정] -->

<bean id="datasource"class="org.springframework.jdbc.datasource.DriverManagerDataSource">

<property name="driverClassName" value="com.mysql.jdbc.Driver"></property>

<property name="url" value="jdbc:mysql://localhost:3306/book_ex?useSSL=false&amp;serverTimezone=Asia/Seoul"></property>

<property name="username" value="root"></property>

<property name="password" value="dudfhd13"></property>

</bean>


c) 데이터 베이스에 연결이 잘 되어 Connection 객체가 잘 얻어지는지 JUnit테스트를 진행하자!


: 스프링에서 test를 진행할 때는 src/test 폴더에서 작업을 해야한다. 

: src/test/패키지경로/ 에다가 DataSourceTest.java 클래스를 하나 만들고

: 다음의 코드를 작성한다.


// JUnit은 WAS 구동없이 테스트를 빠르게 할 수 있기 때문에 단위테스트에 적합하다. 또한, 스프링은 많은 작업을 하고 테스트하려면 오류를 잡기 어려움으로 단위테스트에 신경을 쓰자.

// 스프링 관련 테스트임으로 이 작업을하려면 pom.xml에 spring-test 라이브러리가 추가되어 있어야한다.

@RunWith(SpringJUnit4ClassRunner.class) // 스프링을 로딩한다.

@ContextConfiguration(locations ={"file:src/main/webapp/WEB-INF/spring/**/*.xml"}) // 스프링 설정파일을 로딩한다.

public class DataSourceTest {


@Inject // byType으로 스프링 환경설정파일에 생성해둔 타입이 같은

// 녀석을 자동으로 주입한다.

private DataSource ds;

@Test // 단위테스트할 메서드에 작성한다.

public void testConection()throws Exception{

try(Connection con = (Connection) ds.getConnection()){

// datasource로부터 Connection 객체를 얻어 로그를 찍어보자.

// 메서드를 더블클릭하고 오른쪽 클릭->Run as->Junit Test를 클릭해 Connection 객체가 정상적으로 생성되었다면 DB연결에 성공한 것이다.

System.out.println("테스트 : " + con);

}catch(Exception e){

e.printStackTrace();

}

}

}



2. datasource 객체를 등록했음으로 [ MyBatis(마바) 설정을 하자 ]


a) pom.xml에 아래 라이브러리를 추가한다.

<!-- mybatis -->

<dependency>

<groupId>org.mybatis</groupId>

<artifactId>mybatis</artifactId>

<version>3.4.1</version>

</dependency>


<!-- mybatis-spring -->

<dependency>

<groupId>org.mybatis</groupId>

<artifactId>mybatis-spring</artifactId>

<version>1.2.2</version>

</dependency>


b) classpath경로인 src/resources 에 mapper 폴더를 만들고 그 안에 MyBatisMapper.xml 파일을 만들고 아래 코드를 작성한다.


<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper

PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"

"http://mybatis.org/dtd/mybatis-3-mapper.dtd">


<!-- namespace는 mapper를 구분하는 역할을 함으로 고유한 값으로 지정하지만 보통 프로젝트의 패키지명으로 짓지만 여기선 간단히 작성했다. -->

<mapper namespace="MapperNameSpace">


</mapper>


c) src/resources 아래에 mybatis-config.xml을 추가하고 다음의 코드를 작성한다.

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE configuration

  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"

  "http://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>

  <typeAliases>

    <package name="org.zerock.domain"/>   

    <!-- 

    typeAliases에 패키지 속성으로 지정해 놓으면 mapper에서 resultType시 일일이 패키지명을 다 기재하지 않고 클래스명만 

    기재해 사용할 수 있다. import와 유사함

     --> 

  </typeAliases>

</configuration>


d) 스프링 환경설정 파일 root-context.xml에 가서 다음의 코드를 작성한다.


<!-- MyBatis의 SqlSessionFactory 빈을 등록한다. -->

<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">

<property name="dataSource" ref="datasource"></property>

<property name="configLocation" value="classpath:/mybatis-config.xml"></property>

<property name="mapperLocations" value="classpath:mapper/**/*Mapper.xml"></property>

// 여기서 **는 어떠한 폴더가 와도 괜찮다는 의미 파일명 앞에 *는 아무거나 파일명와도 대고 끝에 Mapper.xml로 끝나는 파일 모두를 의미한다.

</bean>

<!-- MyBatis에서 실질적으로 우리가 사용하게 될 sqlSessionTemplate 빈을 등록하자. -->

<bean id="sqlSession" class="org.mybatis.spring.SqlSessionTemplate">

<constructor-arg index="0" ref="sqlSessionFactory"></constructor-arg>

</bean>


e) JUnit 테스트 코드를 작성하.....자....라 하고 싶은데 너무 귀찮아서 생략하겠다.(잘될거다..)

-----------------------------------------------------------

4. [ AOP와 Transaction 설정 ]


: AOP란?

: OOP(객체지향)에서 기능별로 클래스를 나누었지만, 그럼에도 불구하고 나누어진 클래스들 안에서도 공통으로 사용되는 코드가 중복되기 때문에 여전히 한계점을 지니고 있다. 따라서, 이러한 비지니스 로직이 아닌 공통관심사(횡단관심사)를 proxy(대리자)에게 실행시점에 너가 대신해서 저 비지니스 로직을 실행하는 메서드 앞이나 뒤 혹은 앞 뒤 에 넣어줘!(주입해줘) 처럼 작동하는 방식을 AOP(Aspect Object Programming : 관점지향 프로그래밍)이라고 한다.

ex) 로그, 트랜잭션, 수행성능 테스트 메서드 등..


: AOP 필수 용어

- Advice : 공통관심사, 즉 공통으로 수행할 코드 자체를 의미한다.

- PointCut : 구체적으로 비지니스 로직의 어디에 들어갈지를 지정한다.(앞,뒤 혹은 둘다)

- JoinPoint : PointCut의 묶음이라고 생각하면 쉽다. Advice가 적용될 메서드들

- Aspect : AOP의 하나의 단위라고 보면 된다. 즉, Advice + PointCut을 의미함

- Target : Aspect가 적용될 클래스를 가리킨다.

- Proxy : AOP를 대신 적용하는 대신자 객체

- Weaving : AOP를 적용하는 것을 위빙이라 한다.


여튼... 여기까진 상식적인 얘기고... 설정하는 방법은


Transaction을 어노테이션을 사용하면 저절로 AOP가 적용이 된다. 따라서 트랜잭션을 사용한다는 건, AOP를 사용하는 것과 마찬가지임으로 AOP에서 필요로하는 라이브러리도 필요하다.

즉, AspectJ와 spring-tx를 추가해야한다.


1) pom.xml에 

<!-- 트랜잭션을 위한 spring-tx -->

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-tx</artifactId>

<version>4.3.7.RELEASE</version>

</dependency>

<!-- AspectJ -->

<dependency>

<groupId>org.aspectj</groupId>

<artifactId>aspectjrt</artifactId>

<version>${org.aspectj-version}</version>

</dependency>

가 추가 되어 있는지 확인하자. 일반적으로 legacy project로 생성시 aspectj는 있으니까 spring-tx만 추가해주도록 하자.

-----------------------------------------------------------

5. [ REST 설정 ]


: REST 방식

특정 url이 구체적인 특정 데이터를 의미하는 방식이다.

ex) /replies/123 + DELETE 방식 => 123번 댓글을 지운다.

ex) /replies/ + JSON 데이터 + POST 방식 => 댓글을 추가한다.

이러한 REST 방식은 AJAX와 연동되어서 자주 사용된다.


AJAX?

: 비동기적 통신 방법인데 JSP 화면처리가 싱글쓰레드 기반으로 동작하기 때문에 처리를 할 때 화면이 깜빡이는 것을 우리는 경험했을 것이다.

하지만, 좋아요 버튼 등을 클릭할 때 화면이 깜빡 거린다면...? 극혐

따라서, 싱글 쓰레드처럼 순차적으로 처리하는게 아니라 멀티쓰레드처럼 동시적으로 처리되는 것처럼 느껴지게 하는 방식인데 사실 동시적으로 처리된다기보단, 여유가 될 때 처리를 하도록 해놓고 그 작업을 기다리지 않고 다른 작업을 하다가 그 작업이 처리되면 그 결과를 받는 방식이다.


여튼간.... 서두가 길었고...

설정을 위해서는... JSON 객체를 자동으로 일반 객체로 일반 객체를 반환시

JSON 객체로 자동으로 변환해주기 위해서 필요한 라이브러리가 있다.


1) pom.xml에 jackson-databind 라이브러리를 추가하자.

<!-- REST, AJAX를 위한 라이브러리 -->

<dependency>

<groupId>com.fasterxml.jackson.core</groupId>

<artifactId>jackson-databind</artifactId>

<version>2.8.5</version>

</dependency>


-----------------------------------------------------------

6. [ jdbc용 로그 설정해주기 ]


: 우리가 MyBatis를 이용해 작업을 할 때 최대 단점은?

console에 찍히는 것만 바서는 어떤 변수에 어떤 값이 set되는지 정확히 파악하기가 너무 어렵다. 인정??

따라서, log4jdbc log4j2 라이브러리를 이용하면 이러한게 친절히 다 나온다...


1) pom.xml에

<!-- jdbc log를 위한 라이브러리 -->

<dependency>

<groupId>org.bgee.log4jdbc-log4j2</groupId>

<artifactId>log4jdbc-log4j2-jdbc4</artifactId>

<version>1.16</version>

</dependency>

를 추가하자.


2) 이때 반드시!!!!!!,

- log4jdbc.log4j2.properties

- logback.xml

이 두 파일을 src/resources 아래에 넣어주어야 한다.

이 두 파일은 인터넷에서 구글링해서 찾던지... 기존에 환경설정 되있던대에서 가져와라

-----------------------------------------------------------

6. [ 스프링 MVC 파일 업로드 설정 ]


: 이미지 파일의 경우 이미지를 깨끗하게 축소할 수 있는 imgScalr 라이브러리를 사용한다. 이 라이브러리는 큰 이미지 파일을 고정된 크기로 변환할 때 편리하다.


1) pom.xml에 아래의 라이브러리를 추가한다.

<!-- 파일 업로드 관련 라이브러리 -->

<dependency>

<groupId>org.imgscalr</groupId>

<artifactId>imgscalr-lib</artifactId>

<version>4.2</version>

</dependency>


<dependency>

<groupId>commons-fileupload</groupId>

<artifactId>commons-fileupload</artifactId>

<version>1.3.1</version>

</dependency>

2) 웹에서 파일 업로드는 multipart/form-data라는 방식으로 데이터를 여러 조각으로 나누어서 전송한다. 이때, 스프링 MVC에서 파일 업로드를 처리하기 위해서 파일 업로드로 들어오는 데이터를 처리하는 객체가 필요한데 이 객체를 "multipartResolver"라고 한다. 

이 객체는 웹 설정과 관련이 있기 때문에 root-context.xml이 아니라 servlet-context.xml에 아래의 빈을 등록해 주어야 한다.


<beans:bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">

<beans:property name="maxUploadSize" value="10485760"></beans:property>

</beans:bean>


파일 사이즈는 최대 10M정도로 설정했다.

-----------------------------------------------------------7. [ Spring MVC "인터셉터" 설정 ]


: 인터셉터는 JSP의 필터와 거의 유사하다. Controller로 요청이 들어가기 전에 한번 걸려주는 역할로

게시물 작성할 때 로그인이 필요하듯 게시글 접근 전에 로그인이 되어 있는지 유무등을 검사할 때 자주 사용된다.

하지만, Filter는 웹 애플리케이션 내에서 동작하므로 스프링의 Context를 접근하기 어렵지만, 인터셉터는 Spring의 Context내에서 존재함으로 Context 내의 모든 객체를 활용할 수 있다는 장점이 있다.



설정 방법)

1) 패키지에 SampleInterceptor 클래스를 하나 만들고 HandlerInterceptorAdapter를 extends(상속)한다.


package com.configure.javaStudy.interceptor;


import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;


public class SampleInterceptor extends HandlerInterceptorAdapter{


}


2) WEB-INF/spring/appServlet/servlet-context.xml에 다음 코드를 추가한다.


<!-- 인터셉터를 쓰기 위해 만들어논 SampleInterceptor 클래스를 객체 등록해놓자. -->

<!-- 인터셉터 등록하려면 Namespaces 탭에서 mvc 가 체크 되어 있어야만 함! -->

<!-- interceptor 태그 부분에는 mapping에 원하는 URI를 지정한다. 이 설정은 web.xml의 필터나 servlet의 설정과 

동일함으로 필요한 경로를 직접 지정하거나, **, * 와 같은 패턴을 적용할 수 있다.

-->

<!-- 아래처럼 작성하면 현재 프로젝트의 /doA 경로와 /doB 경로를 호출할 때 SampleInterceptor 클래스가 동작하도록 되어 있는 것! -->

<beans:bean id="sampleInterceptor"

class="com.configure.javaStudy.interceptor.SampleInterceptor">

</beans:bean>


<interceptors>

<interceptor>

<mapping path="/doA" />

<mapping path="/doB" />

<beans:ref bean="sampleInterceptor" />

</interceptor>

</interceptors>


-----------------------------------------------------------


여기까지가 스프링 기본 설정입니다.!! 수고하셨습니다.


- 작성자 : 황영롱 2017-04-28




쿠키와 세션을 이용한 자동 로그인 방식에 대해서 정리해 보겠습니다.


[    1. 쿠키와 세션이란?    ]


: 쿠키와 세션은 매우 유사하면서도 다른 특징을 지니고 있는데요.

- 공통점 : 사용자의 정보(데이터)를 저장할 때 이용된다.

- 차이점 : 

- 쿠키 : 1) 사용자의 로컬에 저장되었다가 브라우저가 요청시 왔다갔다하게 됨(보안에 취약)

     2) 세션과 달리 여러 서버로 전송이 가능함

     3) 세션이 브라우저 단위로 생성되어 브라우저 종료시 사라지는데 반해, 쿠키는 유효시간 설정을 할 수 있음. ex) 7일

- 세션 : 1) 서버에 데이터를 저장하여 쿠키에 비해 보안에 안전함

     2) 브라우저 단위로 생성됨 => 익스플로러를 켜고 크롬을 켜고 하면 각각 2개의 세션이 생성되는 것


[    2. why 쿠키와 세션을 이용한 로그인 처리를 하게 될까?    ]


: 세션은 위에서 설명한대로 기본 단위가 "웹 브라우저"입니다. 따라서, 웹 브라우저 종료시 소멸하게 되죠...

  그에 반해 쿠키는 사용자 PC에 저장되기 때문에 서버 요청시 전달되는 동안 네트워크 상에서 보안상 취약할 수는 있지만 유효시간을

  길게 설정할 수 있어 브라우저가 종료되는 것과 별개로 7일 30일 등 기간을 길게 설정할 수 있습니다.

  하지만, 

  그렇다고 쿠키에 로그인할 사용자의 정보를 담고 있는다면 정말 정말 너무 너무 보안상 취약할 것을 알 수 있겠죠?

  따라서, 자동 로그인을 구현할 때에는 "< 세션과 쿠키를 동시에 사용하는 것 >"이 바람직하다고 생각합니다.


[    3. 세션과 쿠키를 이용한 자동 로그인 구현에 대한 개요    ]


: 사용자가 로그인 폼에서 로그인을 할 당시, 자동로그인을 설정하겠다는 CheckBox를 클릭할 경우 사용자의 정보를 저장시키고 유효

기간을 설정한다는 것 까지는 알겠는데 그럼 도대체 어떤 사용자의 정보를 저장시켜 놓아야할까요?


먼저, 사용자가 로그인에 성공한 경우! -> 세션에 사용자 객체(UserVO)를 저장시켰었는데 앞에서 이 객체를 쿠키에 저장시킨다면, 굉장히 보안상 취약합니다. 비밀번호, 아이디 그 외 정보까지 UserVO에 들어 있었죠...

따라서, 로그인에 성공했을 때 사용자 DB 테이블에 sessionId와 유효시간 속성에 값을 지정하는 겁니다. 그리고 쿠키에는 세션Id를

넣어 놓는거죠... 그리고 "인터셉터"에서 해당 쿠키값이 존재하면 사용자 DB 테이블 내에서 유효시간 > now() 즉, 유효시간이 아직 

남아 있으면서 해당 세션 Id를 가지고 있는 사용자 정보를 검색해 해당 사용자 객체를 반환하는 겁니다.


당연히, 쿠키가 유효시간이 다되면 해당 자동완성 기능은 동작하지 않게 되고 다시 쿠키를 사용하겠다는 선택을 했을 때 동작하게 되겠죠

그럼, 다음으로 코드상에서 직접 한번 알아 봅시다.


[    4. 자동 로그인 실재로 구현해보기    ]


이번 장의 예제는 앞 게시글을 다 수행했다는 가정하에서 진행됩니다.


1) 먼저, UserController에서 로그인에 성공했으면서 사용자가 쿠키 사용 여부를 체크한 경우 -> 쿠키를 생성하고 세팅합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package org.zerock.controller;
 
import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.zerock.domain.UserVO;
import org.zerock.service.UserService;
 
@Controller
public class UserController {
 
    @Inject // byType으로 자동 주입
    UserService service;
 
    // 로그인 폼을 띄우는 부분
    @RequestMapping(value="/login",method=RequestMethod.GET)
    public String loginForm(){
        return "login/loginForm"; // /login/loginForm.jsp를 띄움.
    }// end of loginForm
     
    // 로그인 처리하는 부분
    @RequestMapping(value="/loginProcess",method=RequestMethod.POST)
    public String loginProcess(HttpSession session,UserVO dto, HttpServletResponse response){
        String returnURL = "";
        if ( session.getAttribute("login") != null ){
            // 기존에 login이란 세션 값이 존재한다면
            session.removeAttribute("login"); // 기존값을 제거해 준다.
        }
         
        // 로그인이 성공하면 UserVO 객체를 반환함.
        UserVO vo = service.login(dto);
         
        if ( vo != null ){ // 로그인 성공
            session.setAttribute("login", vo); // 세션에 login인이란 이름으로 UserVO 객체를 저장해 놈.
            returnURL = "redirect:/board/listPage"; // 로그인 성공시 게시글 목록페이지로 바로 이동하도록 하고
         
            /*
             *  [   세션 추가되는 부분      ]
             */
            // 1. 로그인이 성공하면, 그 다음으로 로그인 폼에서 쿠키가 체크된 상태로 로그인 요청이 왔는지를 확인한다.
            if ( dto.isUseCookie() ){ // dto 클래스 안에 useCookie 항목에 폼에서 넘어온 쿠키사용 여부(true/false)가 들어있을 것임
                // 쿠키 사용한다는게 체크되어 있으면...
                // 쿠키를 생성하고 현재 로그인되어 있을 때 생성되었던 세션의 id를 쿠키에 저장한다.
                Cookie cookie = new Cookie("loginCookie", session.getId());
                // 쿠키를 찾을 경로를 컨텍스트 경로로 변경해 주고...
                cookie.setPath("/");
                cookie.setMaxAge(60*60*24*7); // 단위는 (초)임으로 7일정도로 유효시간을 설정해 준다.
                // 쿠키를 적용해 준다.
                response.addCookie(cookie);
            }
        }else { // 로그인에 실패한 경우
            returnURL = "redirect:/login"; // 로그인 폼으로 다시 가도록 함
        }
         
        return returnURL; // 위에서 설정한 returnURL 을 반환해서 이동시킴
    }
 
    // 로그아웃 하는 부분
    @RequestMapping(value="/logout")
    public String logout(HttpSession session) {
        session.invalidate(); // 세션 전체를 날려버림
//      session.removeAttribute("login"); // 하나씩 하려면 이렇게 해도 됨.
        return "redirect:/board/listPage"; // 로그아웃 후 게시글 목록으로 이동하도록...함
    }
} // end of controller

코드를 살펴보면, service 객체의 login메서드를 통해 UserVO 객체를 반환하고 null이 아닌 경우 로그인에 성공했었죠?

이렇게 로그인에 성공되었으면서, 로그인 폼에서 checkBox를 선택한 경우(쿠키 사용하겠다는 체크박스) submit을 했을 때

UserVO 클래스 내의 useCookie 변수에 true/false로 값이 저장되어 들어 왔을 테니까

로그인에 성공했으면서 + 쿠키사용을 체크한 경우에 세션을 추가하도록 하는 부분이 앞에 코드에서 추가된 겁니다.

이때, 사용자 PC에서 쿠키를 보내는 경로가 "/" 로 설정함으로써 contextPath 이하의 모든 요청에 대해서 쿠키를 전송할 수 있

도록 설정한다는 것이고, 유효시간은 (초)단위 임으로 60 * 60 * 24 * 7로 세팅해주면, 로그인 후 해당 쿠키는 7일동안 유지될 수

있게 됩니다.(브라우저의 종료와 관계없이)

이때, 가장 중요하게 볼 부분이 쿠키에 UserVO 객체를 저장하는 것이 아니고!!!!(사실 쿠키는 문자열만 저장되기 때문에 가능하지도 않습니다.)

현재 브라우저의 세션 id를 저장해 놓는 겁니다.


그럼... 쿠키에 의해 자동로그인 기간은 제어가 될 것이고... 사용자는 해당 세션 id에 대한 정보를 가지고 있어야 겠죠??

따라서, 다음으로는 DB의 userTable에 세션Id와 유효시간 정보를 담을 수 있는 컬럼을 추가하도록 합시다.


2) DB userTable에 세션Id와 유효시간을 설정할 수 있는 컬럼을 만들기

1
2
alter table userTable add column sessionkey varchar(50) not null default 'none';
alter table userTable add column sessionlimit timestamp;


3) userMapper.xml에 작업을 합시다.

1. 로그인 성공시 sessionId와 유효시간을 저장하는 부분 작성

2. 사용자가 이전에 로그인에 성공했었는지 확인하는 부분

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
<mapper namespace="org.zerock.mapper.UserMapper">
     
    <!--
        login에 대한 DB 작업을 정의한다. id와 pw가 일치하면 사용자 정보를 담고 있는 객체를 반환한다.
     -->
    <select id="login" resultType="UserVO">
        select * from userTable where userId = #{userId} and userPw = #{userPw}
    </select>
     
    <!--
        로그인된 경우 해당 세션id와 유효시간을 사용자 테이블에 세팅한다.
     -->
    <update id="keepLogin">
        update userTable set sessionKey = #{sessionId}, sessionLimit = #{next} where userId=#{userId}
    </update>
     
    <!--
        유효기간이 남아 있으면서 해당 sessionId를 가지는 사용자 정보를 꺼내오는 부분
     -->
    <select id="checkUserWithSessionKey" resultType="UserVO">
        select * from userTable where sessionKey = #{sessionId} and sessionLimit > now()
    </select>
     
</mapper>


4) userDAO 인터페이스와 userDAOImpl 클래스를 수정합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.zerock.persistence;
 
import java.sql.Date;
 
import org.zerock.domain.UserVO;
 
public interface UserDAO {
 
    public UserVO login(UserVO dto);
     
    // 자동로그인 체크한 경우에 사용자 테이블에 세션과 유효시간을 저장하기 위한 메서드
    public void keepLogin(String uid, String sessionId, Date next);
     
    // 이전에 로그인한 적이 있는지, 즉 유효시간이 넘지 않은 세션을 가지고 있는지 체크한다.
    public UserVO checkUserWithSessionKey(String sessionId);
     
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package org.zerock.persistence;
 
import java.sql.Date;
import java.util.HashMap;
import java.util.Map;
 
import javax.inject.Inject;
 
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;
import org.zerock.domain.UserVO;
 
@Repository
public class UserDAOImpl implements UserDAO {
    @Inject
    SqlSession sqlSession;
 
    /**
     * login에 성공하면, 유저 정보를 담고 있는 UserVO 객체를 반환한다.
     */
    @Override
    public UserVO login(UserVO dto) {
        // Mapper의 namespace명.id : 자신에게 맞게 작성해서 넣는다.
        return sqlSession.selectOne("org.zerock.mapper.UserMapper.login", dto);
    }
 
    // 자동로그인 체크한 경우에 사용자 테이블에 세션과 유효시간을 저장하기 위한 메서드
    public void keepLogin(String uid, String sessionId, Date next){
         
        Map<String, Object> map = new HashMap<String,Object>();
        map.put("userId", uid);
        map.put("sessionId", sessionId);
        map.put("next", next);
         
        // Mapper.xml로 데이터를 전달할 때 한 객체밖에 전달 못함으로 map으로 묶어서 보내줌 단... 주의할 점은
        // Mapper.xml 안에서 #{} 이 안에 지정한 이름이랑 같아야함.. 자동으로 매핑될 수 있도록
        // 아래가 수행되면서, 사용자 테이블에 세션id와 유효시간이 저장됨
        sqlSession.update("org.zerock.mapper.UserMapper.keepLogin",map);
         
    }
 
    // 이전에 로그인한 적이 있는지, 즉 유효시간이 넘지 않은 세션을 가지고 있는지 체크한다.
    public UserVO checkUserWithSessionKey(String sessionId){
        // 유효시간이 남아있고(>now()) 전달받은 세션 id와 일치하는 사용자 정보를 꺼낸다.
        return sqlSession.selectOne("org.zerock.mapper.UserMapper.checkUserWithSessionKey",sessionId);
     
    }
     
 
}


5) UserService 인터페이스와 UserServiceImpl 클래스 수정하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.zerock.service;
 
import java.sql.Date;
 
import org.zerock.domain.UserVO;
 
public interface UserService {
    public UserVO login(UserVO dto);
     
    // 자동로그인 체크한 경우에 사용자 테이블에 세션과 유효시간을 저장하기 위한 메서드
    public void keepLogin(String uid, String sessionId, Date next);
     
    // 이전에 로그인한 적이 있는지, 즉 유효시간이 넘지 않은 세션을 가지고 있는지 체크한다.
    public UserVO checkUserWithSessionKey(String sessionId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package org.zerock.service;
 
import java.sql.Date;
 
import javax.inject.Inject;
 
import org.springframework.stereotype.Service;
import org.zerock.domain.UserVO;
import org.zerock.persistence.UserDAO;
 
@Service
public class UserServiceImpl implements UserService {
    @Inject
    UserDAO dao;
     
    @Override
    public UserVO login(UserVO dto) {
        return dao.login(dto);
    }
 
    @Override
    public void keepLogin(String uid, String sessionId, Date next) {
 
        dao.keepLogin(uid, sessionId, next);
    }
 
    @Override
    public UserVO checkUserWithSessionKey(String sessionId) {
        return dao.checkUserWithSessionKey(sessionId);
    }
}


6) UserController에서 로그인 성공하고 쿠키사용 체크한 경우에 사용자 테이블에 세션id와 유효시간 처리해주기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package org.zerock.controller;
 
import java.sql.Date;
 
import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.zerock.domain.UserVO;
import org.zerock.service.UserService;
 
@Controller
public class UserController {
 
    @Inject // byType으로 자동 주입
    UserService service;
 
    // 로그인 폼을 띄우는 부분
    @RequestMapping(value="/login",method=RequestMethod.GET)
    public String loginForm(){
        return "login/loginForm"; // /login/loginForm.jsp를 띄움.
    }// end of loginForm
     
    // 로그인 처리하는 부분
    @RequestMapping(value="/loginProcess",method=RequestMethod.POST)
    public String loginProcess(HttpSession session,UserVO dto, HttpServletResponse response){
        String returnURL = "";
        if ( session.getAttribute("login") != null ){
            // 기존에 login이란 세션 값이 존재한다면
            session.removeAttribute("login"); // 기존값을 제거해 준다.
        }
         
        // 로그인이 성공하면 UserVO 객체를 반환함.
        UserVO vo = service.login(dto);
         
        if ( vo != null ){ // 로그인 성공
            session.setAttribute("login", vo); // 세션에 login인이란 이름으로 UserVO 객체를 저장해 놈.
            returnURL = "redirect:/board/listPage"; // 로그인 성공시 게시글 목록페이지로 바로 이동하도록 하고
         
            /*
             *  [   세션 추가되는 부분      ]
             */
            // 1. 로그인이 성공하면, 그 다음으로 로그인 폼에서 쿠키가 체크된 상태로 로그인 요청이 왔는지를 확인한다.
            if ( dto.isUseCookie() ){ // dto 클래스 안에 useCookie 항목에 폼에서 넘어온 쿠키사용 여부(true/false)가 들어있을 것임
                // 쿠키 사용한다는게 체크되어 있으면...
                // 쿠키를 생성하고 현재 로그인되어 있을 때 생성되었던 세션의 id를 쿠키에 저장한다.
                Cookie cookie = new Cookie("loginCookie", session.getId());
                // 쿠키를 찾을 경로를 컨텍스트 경로로 변경해 주고...
                cookie.setPath("/");
                int amount = 60 * 60 * 24 * 7;
                cookie.setMaxAge(amount); // 단위는 (초)임으로 7일정도로 유효시간을 설정해 준다.
                // 쿠키를 적용해 준다.
                response.addCookie(cookie);
                 
                // currentTimeMills()가 1/1000초 단위임으로 1000곱해서 더해야함
                Date sessionLimit = new Date(System.currentTimeMillis() + (1000*amount));
                // 현재 세션 id와 유효시간을 사용자 테이블에 저장한다.
                service.keepLogin(vo.getUserId(), session.getId(), sessionLimit);
            }
        }else { // 로그인에 실패한 경우
            returnURL = "redirect:/login"; // 로그인 폼으로 다시 가도록 함
        }
         
        return returnURL; // 위에서 설정한 returnURL 을 반환해서 이동시킴
    }
 
    // 로그아웃 하는 부분
    @RequestMapping(value="/logout")
    public String logout(HttpSession session) {
        session.invalidate(); // 세션 전체를 날려버림
//      session.removeAttribute("login"); // 하나씩 하려면 이렇게 해도 됨.
        return "redirect:/board/listPage"; // 로그아웃 후 게시글 목록으로 이동하도록...함
    }
} // end of controller

아까 쿠키를 생성해서 세션id 저장한 부분 바로 아래에다가 사용자 테이블에도 세션 id와 유효시간을 저장해 놓아야함!


이후, AuthenticationInterceptor의 preHandle() 부분에서 

세션에 UserVO 객체가 null이 아닌 경우는 로그인 되어 있는 부분이니까 그대로 처리되도록 놔두고, 세션의 UserVO 객체가

null이지만, 쿠키가 null이 아닌 경우 쿠키에서 sessionId를 꺼내와서 사용자 객체를 반환받도록 작업할 것이다.


7) AuthenticationInterceptor에서 자동 로그인의 핵심 부분을 처리하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package org.zerock.interceptor;
 
import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.util.WebUtils;
import org.zerock.domain.UserVO;
import org.zerock.service.UserService;
 
// 로그인처리를 담당하는 인터셉터
public class AuthenticationInterceptor extends HandlerInterceptorAdapter{
 
    @Inject
    UserService service;
     
    // preHandle() : 컨트롤러보다 먼저 수행되는 메서드
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
         
         
        // session 객체를 가져옴
        HttpSession session = request.getSession();
        // login처리를 담당하는 사용자 정보를 담고 있는 객체를 가져옴
        Object obj = session.getAttribute("login");
         
        if ( obj == null ){ // 로그인된 세션이 없는 경우...
            // 우리가 만들어 논 쿠키를 꺼내온다.
            Cookie loginCookie = WebUtils.getCookie(request, "loginCookie");
            if ( loginCookie != null ){ // 쿠키가 존재하는 경우(이전에 로그인때 생성된 쿠키가 존재한다는 것)
                // loginCookie의 값을 꺼내오고 -> 즉, 저장해논 세션Id를 꺼내오고
                String sessionId = loginCookie.getValue();
                // 세션Id를 checkUserWithSessionKey에 전달해 이전에 로그인한적이 있는지 체크하는 메서드를 거쳐서
                // 유효시간이 > now() 인 즉 아직 유효시간이 지나지 않으면서 해당 sessionId 정보를 가지고 있는 사용자 정보를 반환한다.
                UserVO userVO = service.checkUserWithSessionKey(sessionId);
                 
                if ( userVO != null ){ // 그런 사용자가 있다면
                    // 세션을 생성시켜 준다.
                    session.setAttribute("login", userVO);
                    return true;
                }
            }
             
            // 이제 아래는 로그인도 안되있고 쿠키도 존재하지 않는 경우니까 다시 로그인 폼으로 돌려보내면 된다.
            // 로그인이 안되어 있는 상태임으로 로그인 폼으로 다시 돌려보냄(redirect)
            response.sendRedirect("/login");
            return false; // 더이상 컨트롤러 요청으로 가지 않도록 false로 반환함
        }
         
        // preHandle의 return은 컨트롤러 요청 uri로 가도 되냐 안되냐를 허가하는 의미임
        // 따라서 true로하면 컨트롤러 uri로 가게 됨.
        return true;
    }
 
    // 컨트롤러가 수행되고 화면이 보여지기 직전에 수행되는 메서드
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {
        // TODO Auto-generated method stub
        super.postHandle(request, response, handler, modelAndView);
    }
     
}

AuthenticationInterceptor에서도 DB에 접근해서 처리를 해야함으로 UserService를 필드변수에 선언해주고 자동 주입을 위해

@Inject해주었다.

또한 preHandle() 메서드에서 로그인 세션이 없으면서 WebUtils를 이용해 쿠키를 가져온 뒤 

로그인 세션이 없지만, loginCookie가 존재하는 경우 웹브라우저를 새로 켜고 로그인을 하지는 않았지만 이전에 로그인 하면서

쿠키 체크를 해논 유효기간이 남아 있는 경우임으로 service.checkUserWithSessionKey() 메서드를 통해 DB에서 유효기간이 남아있고

해당 세션id를 가지고 있는 사용자 정보를 받아온다.

그리고 마지막으로 해당 사용자 정보로 세션의 login을 세팅해주면 자동로그인에 필요한 모든 작업이 완료되었다.


8) 로그아웃 처리(UserController에서...)

로그아웃 처리를 깜빡하고 마지막이랬다.. 하하하....

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 로그아웃 하는 부분
    @RequestMapping(value="/logout")
    public String logout(HttpSession session,HttpServletRequest request, HttpServletResponse response) {
         
        Object obj = session.getAttribute("login");
        if ( obj != null ){
            UserVO vo = (UserVO)obj;
            // null이 아닐 경우 제거
            session.removeAttribute("login");
            session.invalidate(); // 세션 전체를 날려버림
            //쿠키를 가져와보고
            Cookie loginCookie = WebUtils.getCookie(request, "loginCookie");
            if ( loginCookie != null ){
                // null이 아니면 존재하면!
                loginCookie.setPath("/");
                // 쿠키는 없앨 때 유효시간을 0으로 설정하는 것 !!! invalidate같은거 없음.
                loginCookie.setMaxAge(0);
                // 쿠키 설정을 적용한다.
                response.addCookie(loginCookie);
                 
                // 사용자 테이블에서도 유효기간을 현재시간으로 다시 세팅해줘야함.
                Date date = new Date(System.currentTimeMillis());
                service.keepLogin(vo.getUserId(), session.getId(), date);
            }
        }
        return "redirect:/board/listPage"; // 로그아웃 후 게시글 목록으로 이동하도록...함
    }


9) /views/board/listPge.jsp 에서 로그아웃 버튼 하나 넣기

<a href="/logout">[로그아웃]</a> 하나 추가하자 로그아웃 버튼!


10) 결과 확인하기


1) 로그인 하면서 로그인 상태를 기억하시겠습니까를 클릭하고 로그인 하는 모습

2) 로그인 한 직후 모습


3) 로그인 했음으로 글 작성 바로 잘 된다.


4) 로그아웃 버튼을 눌렀다. -> 눌렀지만, 내가 로그아웃 처리후 redirect를 listPage로 했기 때문에 다시 현재 화면이 뜸


5) 하지만 분명한건 로그아웃하고 다시 글등록 누르면 로그인 폼으로 이동된다는거~


여기까지해서 캐시 + 세션 + 인터셉터를 응용한 자동 로그인 구현을 마치겠습니다.


ps) 코드로 배우는 스프링 웹 프로젝트 책을 공부하고 정리한 내용임을 다시 한번 언급드립니다.

pf) 개인적으로 공부한 내용을 정리했습니다. 여러 블로그도 참고하였지만 주로 참고한 것은 "코드로 배우는 스프링 웹 프로젝트"(남가람북스) 책을 공부한 후 참고해 작성하였음을 미리 말씀드립니다.

[    1. 인터셉터란?    ]


특정 URI로 요청시 Controller로 가는 요청을 가로채는 역할을 한다.


[    2. Interceptor와 JSP Filter의 차이??    ]


- 공통점 : 둘 다 Controller로 들어가는 요청을 가로채 특정 작업을 하기 위한 용도로 사용된다.

- 차이점 : 케어할 수 있는 영역(범위)가 다르다. Filter는 같은 웹 어플리케이션 내에서만 접근이 가능하며,

  Interceptor의 경우 스프링에서 관리되기 때문에 스프링내의 모든 객체에 접근이 가능하다.

-> JSP Filter의 경우 주로 한글처리에 이용되고

-> Interceptor의 경우 "로그인 처리"에 이용이 된다.

- why 로그인 처리에 이용?? )

: 만약 인터셉터를 이용하지 않고, 로그인 처리를 한다면, 게시물을 작성("/board/register"), 게시물 수정("/board/modify"),

 게시물 삭제("/board/delete") 등 모든 요청마다 Controller에서 session을 통해 로그인 정보가 남아 있는지를 확인하는 코드

 를 중복해서 입력해야 할 것이다. 

 하지만!, 인터셉터를 이용하면, A, B, C 작업(A,B,C 경로로 요청)을 할 경우에는 ~~Interceptor를 먼저 수행해 session에서 

 로그인 정보가 있는지 확인해 주는 역할을 한다면, 중복 코드가 확 줄어들 수 있을 것이다. 이러한 장점 때문에 사용!


[    3. 인터셉터를 지원하는 인터페이스와 클래스, 메서드    ]


: Spring에서 인터셉터를 지원하기 위해서 

- HandlerInterceptor 인터페이스

- HandlerInterceptorAdapter 추상 클래스를 지원한다. => 요녀석은 위의 인터페이스를 사용하기 쉽게 구현해 놓은 추상클래스.


=> 이때, HandlerInterceptorAdaptor는 3가지 메서드를 제공한다. 이 3가지 메서드를 오버라이딩해서 우리가 이용할 수 있다.

1) public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

: Controller로 요청이 들어가기 전!!에 수행된다.

 : request, response, handler 등의 매개변수를 이용가능한데 우리가 아는 HttpServletRequest, HttpServletResponse,

  이고, 나머지 하나는 이 preHandle() 메서드를 수행하고 수행될 컨트롤러 메서드에 대한 정보를 담고 있는 handle

  이다.

2) postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,

ModelAndView modelAndView)

: 컨트롤러의 메서드의 처리가 끝나 return 되고 화면을 띄워주는 처리가 되기 직전에 이 메서드가 수행된다.

: ModelAndView 객체에 컨트롤러에서 전달해 온 Model 객체가 전달됨으로 컨트롤러에서 작업 후 

: postHandle() 에서 작업할 것이 있다면 ModelAndView를 이용하면 된다.

3) afterCompletion()

: 컨트롤러가 수행되고 화면처리까지 끝난 뒤 호출된다.



[    4. 인터셉터를 이용한 로그인 구현해 보기    ]



1) 로그인 처리를 위한 DB table을 만들자

: 여기서는 mysql을 이용해서 아래와 같이 테이블을 생성하였다.

1
2
3
4
5
6
7
8
9
10
11
create table userTable (
    userId varchar(50) not null primary key,
    userPw varchar(50) not null,
    userName varchar(100) not null,
    userPoint int not null default 0
);
 
insert into userTable(userId,userPw,userName) values('user0','user0','홍길동');
insert into userTable(userId,userPw,userName) values('user1','user1','홍길동1');
insert into userTable(userId,userPw,userName) values('user2','user2','홍길동2');
insert into userTable(userId,userPw,userName) values('user3','user3','홍길동3');

: 이와 같이 id, pw, name, point 를 갖는 테이블을 만들고, 4개 정도 회원 정보를 insert 해 놓았다.


2) 로그인에 사용될 UserVO 클래스를 정의하자.

: DAO(Data Access Object)와 Controller의 ModelAttribute 부분에 사용하기 위해 UserVO를 id와 pwd, name, point 정보 등을

 넣어 아래와 같이 만들어 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package org.zerock.domain;
 
public class UserVO {
    private String userId;
    private String userPw;
    private String userName;
    private int userPoint;
    private boolean useCookie;
     
    public boolean isUseCookie() {
        return useCookie;
    }
     
    public void setUseCookie(boolean useCookie) {
        this.useCookie = useCookie;
    }
    public String getUserId() {
        return userId;
    }
    public void setUserId(String userId) {
        this.userId = userId;
    }
    public String getUserPw() {
        return userPw;
    }
    public void setUserPw(String userPw) {
        this.userPw = userPw;
    }
    public String getUserName() {
        return userName;
    }
    public void setUserName(String userName) {
        this.userName = userName;
    }
    public int getUserPoint() {
        return userPoint;
    }
    public void setUserPoint(int userPoint) {
        this.userPoint = userPoint;
    }
}


3) 로그인처리를 할 MyBatis userMapper.xml 을 작성하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
<mapper namespace="org.zerock.mapper.UserMapper">
     
    <!--
        login에 대한 DB 작업을 정의한다. id와 pw가 일치하면 사용자 정보를 담고 있는 객체를 반환한다.
     -->
    <select id="login" resultType="UserVO">
        select * from userTable where userId = #{userId} and userPw = #{userPw}
    </select>
     
</mapper>

     : 살펴보면, namespace명은 고유한 값을 지정하고 select 문에 호출시 전달받은 userId와 userPw를 통해

 DB에서 select한 뒤 해당 튜플 정보를 resultType = UserVO 로 반환한다.

 이때, #{userId} 부분은 전달 받은 객체에서 getter메서드를 통해 호출됨으로 UserVO의 getUserId() 를 호출해 자동 대입이 될

 것임을 알 수 있다.


4) UserDAO 인터페이스와 이를 구현한 UserDAOImpl 클래스를 작성하자.

먼저) UserDAO

1
2
3
4
5
6
7
8
9
package org.zerock.persistence;
 
import org.zerock.domain.UserVO;
 
public interface UserDAO {
 
    public UserVO login(UserVO dto);
     
}

UserDAOImpl 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.zerock.persistence;
 
import javax.inject.Inject;
 
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;
import org.zerock.domain.UserVO;
 
@Repository
public class UserDAOImpl implements UserDAO {
    @Inject
    SqlSession sqlSession;
     
    /**
     *  login에 성공하면, 유저 정보를 담고 있는 UserVO 객체를 반환한다.
     * */
    @Override
    public UserVO login(UserVO dto) {
        // Mapper의 namespace명.id : 자신에게 맞게 작성해서 넣는다.
        return sqlSession.selectOne("org.zerock.mapper.UserMapper.login", dto);
    }
}

UserDAOImpl은 UserDAO인터페이스를 구현하였고 DAO(Data Access Object)로 DB에 직접 접근하는 처리를 담당하는 

클래스이다. 이 클래스에서는 스프링 환경설정에서 만들어 놓은 mybatis 접근 객체 : 즉, SqlSessionTemplate 객체를 

이용해야함으로 sqlSession.selectOne("MyBatis Mapper의 namespace명.지정한id",넘겨줄 데이터 객체); 를 이용해서 

MyBatis를 통해 작성한 쿼리를 실행해 UserVO 객체를 얻어오는 작업을 해준다. 이때, @Inject나 @Autowired를 통해서

자동 주입을 한다. @Inject와 @Autowired는 ByType(타입이 같은 것을 자동 대입)하는 성질이며, 환경설정 파일에서 우리가

sqlSessionTemplate 빈을 생성해 놓았음으로 자동 대입이 가능하다.(이후, 이런 설명은 생략한다.)

마찬가지로 서비스 객체에서 DAO를 자동주입할 것임으로 UserDAOImpl 클래스 위에 @Repository 어노테이션을 붙였다.

이는, 이 클래스는 DAO 클래스에요! 라는 의미 + 자동으로 new해서 이 객체를 생성해 주세요라는 의미를 가지고 있다.

이후, Service 객체에서도 @Inject를 이용해 자동 주입하기 위해 생성해 놓는다. 단, @Component, @Repoisitory, 

@Service(서비스 자동생성) 등의 어노테이션을 인식하기 위해서는 환경설정 파일에서 <context:component-scan 부분을

잘 지정해야한다. 여기서 지정한 패키지 내에서 해당 어노테이션이 탐색되기 때문이다.


여하튼...

하나의 UserVO 객체가 반환되야 함으로 selectOne() 메서드를 사용한 것이고, 여러 객체를 담고 있는 List로 반환을 받을

경우 selectList() 메서드를 호출한다. 이런 내용은 기본 적인 내용이니까... 이해가 안되면 MyBatis 연동 부분을 살펴보길 바란다.



5) UserService 인터페이스와 UserServiceImpl 클래스를 정의한다.

1
2
3
4
5
6
7
package org.zerock.service;
 
import org.zerock.domain.UserVO;
 
public interface UserService {
    public UserVO login(UserVO dto);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.zerock.service;
 
import javax.inject.Inject;
 
import org.springframework.stereotype.Service;
import org.zerock.domain.UserVO;
import org.zerock.persistence.UserDAO;
 
@Service
public class UserServiceImpl implements UserService {
    @Inject
    UserDAO dao;
     
    @Override
    public UserVO login(UserVO dto) {
        return dao.login(dto);
    }
}

별거 없다... 단순한 서비스 클래스이다...

 dao와 마찬가지로 @Service 어노테이션을 사용해 이 클래스는 서비스 클래스로 사용될 녀석이에요 !! + 자동 객체 생성을 해준다.

 컨트롤러에서 사용되어야 하니까... 역시 이 서비스 클래스가 있는 패키지 경로도 <context:component-scan 이 잘 지정되어 있어야하

 는 것은 당연하다...


자... 여기까지가 기본 세팅이다... 수고했다... 이제 본격적으로 로그인과 관련된 기능을 만들어 보자.

---------------------------------------------------------------------------------------------------------------------------------

6) UserController에서 로그인 처리를 작성해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package org.zerock.controller;
 
import javax.inject.Inject;
import javax.servlet.http.HttpSession;
 
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.zerock.domain.UserVO;
import org.zerock.service.UserService;
 
@Controller
public class UserController {
 
    @Inject // byType으로 자동 주입
    UserService service;
 
    // 로그인 폼을 띄우는 부분
    @RequestMapping(value="/login",method=RequestMethod.GET)
    public String loginForm(){
        return "login/loginForm"; // /login/loginForm.jsp를 띄움.
    }// end of loginForm
     
    // 로그인 처리하는 부분
    @RequestMapping(value="/loginProcess",method=RequestMethod.POST)
    public String loginProcess(HttpSession session,UserVO dto){
        String returnURL = "";
        if ( session.getAttribute("login") != null ){
            // 기존에 login이란 세션 값이 존재한다면
            session.removeAttribute("login"); // 기존값을 제거해 준다.
        }
         
        // 로그인이 성공하면 UserVO 객체를 반환함.
        UserVO vo = service.login(dto);
         
        if ( vo != null ){ // 로그인 성공
            session.setAttribute("login", vo); // 세션에 login인이란 이름으로 UserVO 객체를 저장해 놈.
            returnURL = "redirect:/board/listPage"; // 로그인 성공시 게시글 목록페이지로 바로 이동하도록 하고
        }else { // 로그인에 실패한 경우
            returnURL = "redirect:/login"; // 로그인 폼으로 다시 가도록 함
        }
         
        return returnURL; // 위에서 설정한 returnURL 을 반환해서 이동시킴
    }
 
    // 로그아웃 하는 부분
    @RequestMapping(value="/logout")
    public String logout(HttpSession session) {
        session.invalidate(); // 세션 전체를 날려버림
//      session.removeAttribute("login"); // 하나씩 하려면 이렇게 해도 됨.
        return "redirect:/board/listPage"; // 로그아웃 후 게시글 목록으로 이동하도록...함
    }
} // end of controller

: 먼저 로그인 요청시 로그인 폼 화면을 띄워주는 부분을 작성해야 한다.

  loginForm() 부분이 바로 그 부분! RequestMapping된 메서드의 return한 부분은 환경설정 파일의 ViewResolver에 맵핑

  됨으로 loginForm.jsp 파일을 view아래에 적당한 위치에 만든다음에 return을 잘해서 http://localhost:8080/login으로 요청시

  해당 웹페이지가 뜨도록 설정한다. 주소에 url 요청시 GET 방식임으로 GET으로 설정한다.


 그 다음은, 로그인을 처리하는 부분이다. 로그인을 처리할 때 해야할 작업은 2가지이다.

1) 새로 로그인 요청이 온다면, 기존에 세션에 저장되어 있던 이전 로그인 사용자의 정보를 제거해 주어야 한다.

2) 새로 로그인 하는 사용자의 정보가 일치한다면, 해당 사용자 정보를 DB에서 가져와 세션에 저장해 놓아야 한다.

 

service.login()을 수행->dao.login()->UserMapper.xml 순으로 수행되며 id와 pw가 일치하면 사용자 정보를 담은 UserVO를 반환

할 것이고, 틀렸다면 null이 반환될 것이다. 

null 이 아닐 경우, 세션을 통해 사용자 객체를 저장해 놓는다.


이때, 마지막으로 작업해 주어야 하는 부분이 어디로 이동하느냐에 대한 정보이다.

로그인에 실패할 경우 return "redirect:/login"; 을 통해 다시 로그인 폼으로 이동시켜 주어야 할 것이고,

로그인에 성공해 session에 사용자 객체를 저장한 이후에는 return "redirect:/board/listPage" 와 같이 최초 페이지로 이동시켜주어

야 할 것이다.


로그아웃 부분은 그냥 로그아웃 url 요청이 있으면 해당 브라우저의 세션을 통째로 날려버리면 된다.

물론, session.removeAttribute('변수명") 해서 하나를 날려도 되지만, 만약 날려야하는 정보가 여러개라면 일일이 하기 힘들 수 있

기 때문에 invalidate() 를 많이 사용한다.


===> 여기까지 작업을 완료했다면 로그인, 로그아웃 기능은 다 만든 것이다. 하지만, 가장 중요하게 처리해 주어야 할 부분이

남아 있다.!!


** 게시물을 등록, 수정, 삭제 등을 할 때는 반드시 로그인 된 사용자만 할 수 있도록 "인터셉터"를 적용하는 것 말이다.!! ***


7) AuthenticationInterceptor 를 만들어 게시물 등록, 수정, 삭제 요청 전에 로그인 여부를 확인하는 인터셉터 클래스를 작성하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package org.zerock.interceptor;
 
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
 
// 로그인처리를 담당하는 인터셉터
public class AuthenticationInterceptor extends HandlerInterceptorAdapter{
 
    // preHandle() : 컨트롤러보다 먼저 수행되는 메서드
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // session 객체를 가져옴
        HttpSession session = request.getSession();
        // login처리를 담당하는 사용자 정보를 담고 있는 객체를 가져옴
        Object obj = session.getAttribute("login");
         
        if ( obj == null ){
            // 로그인이 안되어 있는 상태임으로 로그인 폼으로 다시 돌려보냄(redirect)
            response.sendRedirect("/login");
            return false; // 더이상 컨트롤러 요청으로 가지 않도록 false로 반환함
        }
         
        // preHandle의 return은 컨트롤러 요청 uri로 가도 되냐 안되냐를 허가하는 의미임
        // 따라서 true로하면 컨트롤러 uri로 가게 됨.
        return true;
    }
 
    // 컨트롤러가 수행되고 화면이 보여지기 직전에 수행되는 메서드
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {
        // TODO Auto-generated method stub
        super.postHandle(request, response, handler, modelAndView);
    }
     
}

먼저 Interceptor를 구현하기 위해서는 가장 쉽게 사용할 수 있는 것이 HandlerInterceptor 인터페이스를 구현해 놓은 HandlerInterceptorAdapter이다.


이를 extends해서 preHandle()과 postHandle() 을 오버라이딩한다. shift + alt + s + v 를 하면... 알죠...?

보통, 세션에 로그인 사용자 정보를 컨트롤러에서 저장하지 않고 여기서 저장할 것이었으면 postHandle() 부분에서 수행해 주어야 겠

지만, 우리는 간편하게.....(그냥 귀찮았어...ㅠㅠ) 컨트롤러에서 다 해버렸음으로 인터셉터 클래스에서는 preHandle() 메서드만 필요하다.


그럼, preHandle() 에서는 request에서 세션 객체를 가지고 오고 세션에서 login변수에 사용자 정보 객체가 담겨 있나 확인을 한다.

없으면?? response.sendRedirect("uri") 를 통해 uri경로로 날려버리면 된다. 사용자 정보가 세션에 없다는건 지금 로그인이 되어 있지

않다는 것을 의미함으로 response.sendRedirect("/login") 을 통해서 다시 로그인하는 폼으로 이동시켜버리면 된다.

단, 이때 이 메서드를 수행 후 return false를 한 이유는, 이 preHandle() 메서드의 return 이 의미하는 바는 true일 경우 preHandle() 

메서드를 수행한 뒤에 본래 요청한 Controller 를 수행한다는 의미이고 false를 주면 수행하지 않는다는 의미를 담고 있다.

따라서, 로그인 안된 상태에서 요청시 해당 컨트롤러로 요청이 가지 않도록 false를 해주자.


반대로 사용자 정보가 세션에 담겨 있는 경우에는 단순히 return true를 해줘서 본래 사용자가 요청했던 Controller의 RequestMapping부분이 수행될 수 있도록 해주면 된다.


8) 가장 중요한... servlet-context.xml에 인터셉터 설정 정보를 등록해야한다..


: 일단, 인터셉터 자체는 웹 관련 설정임으로 root-context.xml이 아닌 servlet-context.xml에 작성하자. 물론, 요소마다 설정파일을

  다 나누어서 설정하셨다면... 본인에 맞는 환경설정 파일에서 작업을 해주세요...


이때, 인터셉터 빈 객체를 생성해 놓고, 인터셉터 정보를 등록할 때 해당 객체와 어떤 url이 요청시에 인터셉터가 작동할지를

설정해 놓아야 한다.


여기서는 필요한 uri를 하나하나 지정했는대 이렇게 하지 않고, 전체 경로 /** 로 잡아 놓고

<exclue-mapping path="예외url" /> 을 지정해서 모든 페이지에 인터셉터를 적용하지만 예외는 ~다 라고 지정할 수도 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?xml version="1.0" encoding="UTF-8"?>
 
    <!-- DispatcherServlet Context: defines this servlet's request-processing infrastructure -->
     
    <!-- Enables the Spring MVC @Controller programming model -->
    <annotation-driven />
 
    <!-- Handles HTTP GET requests for /resources/** by efficiently serving up static resources in the ${webappRoot}/resources directory -->
    <resources mapping="/resources/**" location="/resources/" />
 
    <!-- Resolves views selected for rendering by @Controllers to .jsp resources in the /WEB-INF/views directory -->
    <beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <beans:property name="prefix" value="/WEB-INF/views/" />
        <beans:property name="suffix" value=".jsp" />
    </beans:bean>
     
     
    <!-- 인터셉터 객체 생성 -->
    <beans:bean id="authenticationInterceptor" class="org.zerock.interceptor.AuthenticationInterceptor">
    </beans:bean>
    <!-- Interceptor 설정 -->
    <interceptors>
        <interceptor>
            <mapping path="/board/register"/>
            <mapping path="/board/modify"/>
            <mapping path="/board/delete"/>
            <beans:ref bean="authenticationInterceptor"/>
        </interceptor>
    </interceptors>
     
    <context:component-scan base-package="org.zerock.web" />
    <context:component-scan base-package="org.zerock.controller" />
     
     
     
</beans:beans>


9) 죄송합니다... 기존거에서 빠트린 부분이 있어 이부분 추가합니다. -> "자기 글만 수정, 삭제가 가능하도록"


여러분... 제가 이 부분을 빠트렸었내요 ㅠㅠ... 지금은 로그인만하면, 모든 게시글들을 다 삭제하고 수정할 수 있죠...

그러면 큰일나자나요... ㅠㅠ

자기가 작성한 글만 수정, 삭제를 할 수 있도록 구성해야합니다. 어떻게하면 좋을까요... 여러 방법이 있겠지만 전 2가지

정도 생각이 나내요... 인터셉터의 postHandler() 메서드를 통해서 요청 uri를 분석한다음 수정일 경우 postHandler()에서 전달

받은 ModelAndView로 사용자 정보를 전달받아서 하는 방법이 있겠고... 근대 이건 좀 귀찮고...


사실... 훨씬 간단한 방법이 있어요... 뷰 페이지에서 처리하면 말이죠...


머냐....


일단, 수정버튼과 삭제 버튼은 해당 글을 클릭했을 때만 뜬답니다. 그렇죠? 그러면 글을 읽는 /views/board/read.jsp 페이지에서

<c:if test="${login.userId eq boardVO.writer}">


</c:if>

인 경우에만 수정하고 삭제 버튼이 보이도록하면 어떨까요? 세션에 login 변수로 userVO 객체를 담고 있으니까

현재 로그인한 사용자 userId와 현재 게시글의 boardVO 에서 writer를 비교해서 eq(같은경우)만 수정과 삭제 버튼이 보이고

목록으로가는 버튼은 항상 보이도록 하는거죠.

그러면 별다른 처리없이 안보이면 사용자는 수정과 삭제버튼은 클릭조차 할 수 없기에 모든 처리가 끝나게 되죠..

아래처럼 /views/board/read.jsp 페이지를 수정합시다.

1
2
3
4
5
6
7
<div class="box-footer">
    <c:if test="${login.userId eq boardVO.writer }">
        <button type="submit" class="btn btn-warning">수정</button>
        <button type="submit" class="btn btn-danger">삭제</button>
    </c:if>
    <button type="submit" class="btn btn-primary">목록으로</button>
</div>

아직 댓글에 대한 처리를 안했지만, 댓글도 마찬가지로 이와 같은 방식으로 해주면 되겠죠? 


10) 결과를 확인해 보자!!!

메인페이지)



로그인이 되어 있지 않은 상태에서 글등록을 누르면, 로그인 폼 페이지로 이동된다.


로그인에 성공하면, 게시글 첫 페이지로 이동된다.



이제 로그인이 된 상태임으로 "글등록"을 누르면 정상적으로 글 등록 폼으로 이동된다.


그리고 마지막으로, 로그인은 한 상태지만 다른 사람의 글을 클릭했을 때 수정과 삭제가 보이지 않고 목록으로 버튼만 보이는 화면입니다.



이상입니다.


열심히 끝까지 읽어주셔서 감사합니다.



+ Recent posts