3장부터 12장 까지는 하나의 프로젝트를 단계적으로 수행하는 형태로 진행됩니다.
질문과 답변을 받는 웹 서비스를 구현하는게 목표이고, 이번 장 에서는 요구사항 확인, 웹 어플리케이션의 개밣 환경 구축 그리고 서버 실습 환경을 구축 해보도록 합니다.
3.1 서비스 요구사항
- 질문/답변 게시판에 처음 접근하면 질문 목록을 볼 수 있다.
- 질문 목록 화면에서 회원가입, 로그인, 로그아웃, 개인정보 수정이 가능하고 질문하기 화면으로 이동 가능
- 질문 목록 화면에서 각 질문 제목을 클릭하면 각질문의 상세보기 화면으로 이동
- 상세보기 화면에서는 답변을 추가하고, 질문과 답변의 수정/삭제가 가능
3.2 개발 환경 구축
프로젝트를 진행하기 위해 github 저장소 로부터 프로젝트를 clone 하고 적용하는 방법에 대한 영상은 아래에 있습니다.
아래는 저장소 링크인데요 책에 있는 링크로 들어가 제공해주는 코드를 보았는데 이클립스에서 실행하고 빌드 툴은 메이븐을 사용하는 프로젝트여서
제가 평소 개발하는 방식에 맞춰서 인텔리j와 그래들에서 동작하는 프로젝트로 커스텀을 했습니다. 미흡한 부분이 있을 수 있다는거 참고 부탁드립니다.
- 3 ~ 6장 실습 저장소 : 기존 - 이클립스,메이븐 / 커스텀 - 인텔리j, 그래들
- 6 ~ 12장 실습 저장소 : 기존 - 이클립스,메이븐 / 커스텀 - 인텔리j, 그래들
3.3 원격 서버에 배포
로컬환경에서 개발된 어플리케이션을 원격서버 배포하는 방법에 대해 나와있는데 이건 본인이 따로 서버를 가지고 있다면 그걸 이용해도 되고,
따로 없다면 AWS 를 이용해 웹 서버를 구축해도 된다.
AWS를 이용한 서버 구축은 오픈 튜토리얼 수업을 참고하자.
배포 순서
- 서버에 접속 후 계정의 Home 디렉토리에 Github 저장소를 clone한다.
- clone한 디렉토리로 이동해 아래 명령어를 입력해 프로젝트를 빌드한다.
Gradle :./gradlew clean build
Maven :mvn clean package
- 빌드가 끝나면 아래 명령어를 실행한다. $PORT는 1024보다 큰 숫자를 지정한다.
Gradle :./gradlew run --args=$PORT &
Maven :java -cp target/classes:target/dependency/* webserver.WebServer $PORT &
- $PORT를 8080으로 했다면,
curl http://localhost:8080
을 실행해 Hello World 메시지가 찍히는지 확인한다.
배포 과정 참고 영상
리눅스, 터미널 참고 자료
프로젝트 실습
요구사항 1 - index.html 응답하기 (영상)
책에 있는 요구사항을 간단하게 요약 후 직접 구현해보기로 합니다.
직접 구현 후에 영상을 한번 보면 박재성님의 설명과 함께 내가 구현한 내용을 비교해보는것도 도움이 되었습니다.
http://localhost:8080/index.html 로 접속했을때 webapp폴더의 index.html이 노출되게끔 한다.
힌트 | 내용 |
---|---|
1단계 | - InputStream을 한 줄 단위로 읽기 위해 BufferedReader를 생성 - BufferedReader.readLine() 메소드로 한줄씩 HTTP 요청 정보를 읽음 - 헤더 마지막 체크는 while (!””.equals(line)) {} 으로 확인 가능 - line이 null 값일때 처리. if (inle == null) { return; } |
2단계 | - HTTP 요청 정보의 첫 라인에서 요청 URL을 추출한다. - line.split(“ “)을 활용한다. |
3단계 | - 요청 URL에 해당하는 파일을 webapp디렉토리에서 읽어서 전달한다. - byte[] body = Files.readAllBytes(new File(“./webapp” + url).toPath()); |
위의 요구사항과 힌트를 가지고 RequestHandler의 run 메소드를 수정했습니다.
아래 코드는 아직 리팩토링 하기 전에 코드입니다.
1 | try ( |
위 코드에 요구사항을 구현하기전에 try-catch문이 처음 보는 형태여서 검색을 해보았습니다. 1
try-catch-resources라고 불리는건데 자바7부터 추가 되었으며 try 선언시 괄호 안에 close가 필요한 리소스를 넣어주면 try-catch가 종료되면서 자동으로 close() 메소드를 호출해 준다고 합니다.
처음보는 형태라 조금 당황했지만 한편으로는 Java의 신규기능에 대해서도 학습 해야할거 같습니다.
위에서 URL 데이터를 가져오기위해 첫번째 헤더를 split하는 부분은 HttpRequestUtils 클래스에 함수로 따로 빼서 리팩토링 할 예정입니다.
요구사항 2 - GET 방식으로 회원가입 하기 (영상)
회원가입 메뉴를 클릭하면 http://localhost:8080/user/form.html 로 이동하면서 회원가입 할 수 있도록 구현하는것이 목표고 아래와 같은 형태로 입력한 값이 서버에 전달되게 해야합니다.
1 | /user/create?userId=javajigi&password=password&name=JaeSung&email=javajigi%40slipp.net |
사용자가 입력한 값을 파싱해 model.User 클래스에 저장합니다.
힌트 | 내용 |
---|---|
- HTTP 요청의 첫번째 라인에서 요청 URL을 추출한다. - 요청 URL에서 접근 경로와 이름=값 으로 전달되는 데이터를 추출해 User 클래스에 담는다. - 단위 테스트를 통해 효과적인 개발을 한다. - 이름=값 파싱은 util.HttpRequestUtils 클래스의 parseQueryString() 메소드를 이용한다. - 요청 URL과 QueryString을 분리한다. subString 사용 |
index.html에서 회원가입 버튼을 눌러 나오는 form에 정보를 입력하고 회원가입 버튼을 누르면 정상적으로 서버에 요청되는것을 확인하였습니다. (혹시라도 html 쪽 구현도 해야하나 한번 확인을 위해)
1 | String url = HttpRequestUtils.getUrl(line); |
우선은 /user/create로 URL이 시작될때 URL의 뒷부분인 QueryString 값을 가져와서 parseQueryString 메소드를 이용해
각각의 값을 User 객체 생성자에 param값으로 넣어주었습니다.
데이터 베이스 연동 및 User객체 생성 이후의 처리는 다른 요구사항을 처리해 나가면서 어떻게 할지 고민해봐야 할거 같습니다.
요구사항 3 - POST 방식으로 회원가입 하기 (영상)
http://localhost:8080/user/form.html 파일의 from 태스 method를 get에서 post로 수정 후 정상적인 회원가입 처리
힌트 | 내용 |
---|---|
- POST로 데이터 전송시 데이터는 body에 담긴다. - HTTP body는 헤더 이후 빈 공백을 가지는 한 줄 다음부터 시작한다. - body에 들어있는 데이터는 key=value 형태를 가진다. - BufferedReader에서 body 데이터는 util.IOUtils 클래스의 readData 메소드를 활용한다. - body의 길이는 헤더의 Content-Length 값이다. |
우선 webapp/user/form.html 파일의 78번째 줄에 있는 form 태그의 method를 post로 변경합니다.
1 | <!-- 변경전 코드 : <form name="question" method="get" action="/user/create"> --> |
그 다음 요구사항에 맞게 코드를 추가합니다.
1 | String url = HttpRequestUtils.getUrl(line); |
요구사항 1번에서 Header 데이터를 확인해보기위해 while문을 이용해 출력을 했는데, 이번엔 그 코드를 이용해 Header의 데이터를 한줄씩 불러와서 key-value형태로 Map에 저장하도록 구현하였습니다.
/user/create URL로 요청이 들어올때 IOUtils.readData() 메소드에 헤더정보를 가지고 있는 bufferedReader와, Content-Length 데이터를 넘겨주면 bodyData를 return 해줍니다.
여기서 readData 메소드는 BufferedReader 클래스의 read 메소드를 이용해 구현되었고, read 메소드가 2가지가 있는데 param값이 필요한 메소드를 사용하고 있습니다. 해당 메소드는 bufferedReader에 있는 값을 입력받은 길이만큼 잘라서 배열에 넣는 메소드 입니다.
Content-Length는 body 데이터의 길이를 나타내기 때문에 bufferedReader에서 body에 해당하는 부분을 Content-Length 길이만큼 자르면 body 데이터가 됩니다.
참고로 IOUtils.readData 메소드 호출시 넘겨주는 bufferedReader는 body가 시작되는 시점이어야 합니다.
힌트의 두번재 쭐에 나와있는것 처럼 헤더가 모두 끝나고 한줄 이후 부터 body가 시작되기 때문에 while문에서 header를 모두 읽고나면
자연스레 bufferedReader는 body가 시작되는 지점이 됩니다.
처음엔 while을 돌면서 Content-Length가 포함된 라인까지만 확인하고 바로 readData를 호출했더니 정상적으로 body데이터를 가져오는 문제가 있었는데
위의 내용을 알지 못한채로 진행해서 발생한 문제였습니다.
요구사항 4 - 302 status code 적용 (영상)
현재까지 구현한 내용으로는 /user/create 요청시 따로 응답 처리를 해주지 않았으므로 페이지가 정상적으로 노출되지 않습니다.
회원가입을 완료하면 /index.html 페이지로 이동시켜봅시다.
힌트 | 내용 |
---|---|
- HTTP 응답 헤더를 200이 아니라 302 code를 사용한다. - http://en.wikipedia.org/wiki/HTTP_302 참고 |
302 코드2는 일시적으로 URL을 리다이렉트 되었음을 알려주는 응답코드이고, 헤더에 리다이렉트 시킬 URL을 작성하면
브라우저가 해당 URL로 리다이렉트 시킵니다.
프로젝트 템플릿을 열었을때 RequestHandler 클래스에 response200Header라는 메소드가 이미 구현되어 있었는데요 해당 메소드는 클라이언트에게 상태코드 200과 리스폰스 될 데이터의 길이(Content-Length)를 전달해서 정상적으로 요청이 진행되었음을 알려주도록 처리 하는 메소드 인데요.
이번엔 200 코드 대신 302코드를 돌려줘야하니 response302Header라는 메소드를 추가로 만들고
/user/create로 요청이 들어왔을때 해당 메소드를 호출시켜주면 될거 같습니다.
1 | private void response302Header(DataOutputStream dos) { |
힌트의 두번째 줄에 있는 링크를 참고해서 302 코드의 서버 리스폰스 값의 형태를 볼 수 있는데요 이걸 참고로 메소드를 만들었습니다. Location 값에 리다이렉트 시켜줄 URL을 넣으면 되는데요 해당 부분에 /index.html 대신 https://www.naver.com 을 넣으면 /user/create 호출시 네이버 홈페이지로 리다이렉트 되는걸 확인하실 수 있습니다.
response302Header 메소드는 위와같의 구현하고 해당 메소드를 호출해주는 부분은 아래와 같이 구현합니다.
1 | if(url.startsWith("/user/create")) { |
else를 사용해서 클라이언트에 돌려줄 리스폰스 값을 다르게 설정해줄 수 있게 구현했는데요.
DataOutputStream이 중복으로 쓰여지는 부분도 있고, 클라이언트에 리스폰스 하는 기능을 하나의 메소드로 묶어서 처리할 수 있을거 같아서 위 코드도 리팩토링을 통해서 더 깔끔하게 구현 할 수 있을거 같습니다.
요구사항 5 - 로그인하기 (영상)
로그인 메뉴를 클릭해서 /user/login.html으로 이동해 로그인을 시도해봅니다.
로그인이 성공하면 /index.html로, 실패하면 /user/login_failed.html로 이동시킵니다.
요구사항 3번에서 진행한 회원가입된 사용자로 로그인 할 수 있게 처리해야하고, 로그인시 쿠키값에 로그인 상태를 유지하도록 한다.
로그인 성공시 logined=true, 로그인 실패시 logined=false를 이용한다.
힌트 | 내용 |
---|---|
1단계 | - 로그인 성공시 HTTP 응답 헤더에 Set-Cookie를 추가해 로그인 성공 여부를 전달한다. - 이후 요청시 전달받은 Cookie값으로 로그인 유무를 판단한다. |
2단계 | - 회원가입한 데이터를 유지하기 위해 DataBase.addUser 메소드를 활용한다 - 아이디와 비밀번호가 같은지를 확인해서 로그인 처리를 해줍니다. |
이전에 회원가입 요구사항 처리시 TODO로 진행했던 부분을 DataBase 클래스의 addUser 메소드를 이용해 데이터를 저장하고, 로그인 URL로 요청이 들어오면 body에 들어온 id를 가지고 DataBase에 있는 데이터에서 조회 후 비밀번호가 일치하면 정상적으로 로그인되었다 처리하면 될거 같습니다. 그리고 response200Header에서 Set-Cookie 항목을 추가해서 넣어주면 이번 요구사항도 처리가 될거 같습니다.
1 | } else if(url.equals("/user/login")) { |
URL이 /user/login일때 처리를 별도로 해주기 위해서 else if로 분기 처리 하였습니다. 조건에 startWith대신 equals로 바꿨는데요 로그인페이지 호출은
‘/user/login.html’, 로그인 시도는 ‘/user/login’ 이어서 startWith로 처리하면 정상적으로 동작하지 않은 문제가 있어서 equals로 변경했습니다.
페이지 호출과 API 호출시에는 URL을 다르게 하던가, method 타입을 확인해서 GET 방식일땐 페이지 호출, POST 방식일땐 API 호출로 처리하는게 더 좋을거 같다는 생각입니다.
그리고 response 해줄때 책의 힌트에는 상태 코드가 200이어서 처음엔 response200HeaderWithCookie로 작업을 처리했는데요. 그러다보니 로그인 성공후 index.html로 이동시에 페이지는 변경되었으나 URL은 /user/login로 유지가 되는 상태가 발생해서 302 코드로 redirect 시켜주는 형태로 변경했습니다.
(강의 동영상에도 302코드로 구현되어 있습니다.)
그리고 로그인 실패시에는 /user/login_failed.html로 이동시켜야해서 response 메소드에 따로 redirect URL을 받아서 처리하게끔 구현하였습니다.
요구사항 6 - 사용자 목록 출력 (영상없음)
사용자가 로그인 상태일경우 /user/list로 접근했을 때 사용자 목록을 출력한다. 로그인 하지 않은 상태면 /login.html로 이동한다.
힌트 | 내용 |
---|---|
- 로그인 상태 확인을 위한 Cookie 파싱은 HttpRequestUtils 클래스의 parseCookies() 메소드를 활용한다. - String 값을 boolean 으로 변환하는 메소드는 Boolean.parseBoolean() 메소드를 활용한다. - 자바 클래스중 StringBuilder를 활용해 사용자 목록을 출력하는 HTML을 동적으로 생성한 후 응답으로 보낸다. |
우선 URL이 /user/list일때 쿠키값을 받아와서 logined 라는 이름의 쿠키의 유/무, 있다면 false인지 true인지 확인을 해서 로그인 상태를 체크합니다.
로그인이 되어있는 상태면 DataBase 클래스의 findAll() 메소드를 이용해 저장된 데이터를 모두 가져오고, StringBuilder를 이용해 HTML코드를 만든뒤
webapp/user/list.html 파일의 유저 목록에 해당하는 부분에 append 시켜서 데이터로 넘겨주면 될거 같습니다.
1 | else if (url.startsWith("/user/list")) { |
1 | <tbody> |
webapp/user/list.html 파일에 기본적으로 2개의 샘플 유저 목록이 존재하고 있길래 그건 유지하고 그 이후에 추가하는 형태로 구현을 해보았습니다. 그래서 index값이 3부터 시작하게 설정하고, 각 유저의 데이터를 tr태그로 만들어서 StrinbBuilder에 추가하고
list.html에 StrinbBuilder로 만든 데이터를 추가해야 했어서 list.html에 tr태그가 들어가야할 위치에 %user_list% 문자열을 추가하고 해당 부분을 replace 해주는 식으로 구현을 했습니다. DataBase에 데이터가 들어갈때 한글과 특수문자는 encoding이 되어 들어가게 되어서 decoding 하지 않고 출력하면 한글이 깨져서 노출됩니다. 그래서 UTF-8로 decoding 해서 데이터를 만들어 주었습니다.
- encoding, decoding, UTF-8등 웹에서 Character Set을 다루는 방법에 대해서 좀더 정확하게 알아야할 필요가 있을거 같습니다.
요구사항 7 - CSS 지원하기 (영상)
지금까지 프로젝트를 하면서 모든 페이지는 아래와 같이 CSS를 지원하지 않고 있었습니다.
이번 요구사항은 CSS를 적용해보는 것입니다.
힌트 | 내용 |
---|---|
- 응답 헤더의 Content-Type를 text/html로 보내면 브라우저는 HTML파일로 인식하기 때문에 CSS가 작동하지 않음. - CSS는 Content-Type을 text/css로 보내야한다. Content-Type은 확장자를 통해 구분하거나, 요청 헤더의 Accept를 활용할 수도 있다. |
힌트에서 나와있는거 같이 Response의 Content-Type을 text/css로 바꿔서 처리해주면 되고, CSS 요청은 헤더의 Accept를 확인해보면 될거 같습니다.
1 | else { |
CSS요청은 URL을 가지고 분기처리 하는 부분에서 else에 해당하고 responseHeader를 구성하는 메소드를 호출하는 부분에서 헤더의 Accept를 가지고와서 text/css가 존재하는지 여부를 판단해 response200HeaderWithCss() 메소드를 호출해주면 아래와 같이 정상적으로 이미지가 노출됩니다.
정리
이로써 3장의 웹 서버 실습 요구사항을 모두 적용했는데요 각 요구사항을 직접 작업해보고 동영상 강의를 들으면서 부족한점을 보충 하는 식으로 진행했습니다.
위에 있는 코드는 아직 리팩토링 하기 전 코드라서 전체 코드로 보면 지저분하지만 리팩토링을 한번 전체적으로 해놓고 테스트 코드도 한번 추가하고, 샘플에서는 img 호출은 없었는데 img호출시에는 어떻게 처리되는지도 보아야 할거 같습니다.
그 외에도 진행하면서 StringBuffer, StringBuilder, Header, Status Code등 뭔지는 알고 있지만 정확하게 설명을 하거나, 자유자재로 사용하기 어려운 부분에 대해서는 따로 추가 공부를 하는 시간을 가지는게 좋을거 같습니다.
위에서 작업한 코드는 여기에 있습니다.
Github 공부
- 누구나 쉽게 이해할 수 있는 Git 입문
- Git 설치 간편 안내서
- Git의 commit과 push의 개념
- Git 심화 공부
- Git 15분만에 배우는 실습
- 브랜치 rebase 등을 배우는 실습
- 생활 코딩 Git 강좌
- progit, 공짜책
- GUI 도구
Maven 공부
- 메이븐 도구 학습
- 빌드 도구 설명, 메이븐 프로젝트 생성, 의존성 관리
- 부모 pom, 기본 디렉토리 설정, 메이븐 기본 명령어
- 메이븐 phase, goal, 플러그인 설정 재정의
- slipp 프로젝트에 메이븐 적용
로깅
로그레벨은 TRACE < DEBUG < INFO < WARN < ERROR 순입니다. 로그 레벨이 높을수록 출력메시지가 적고, 낮을수록 더 많은 로그가 출력된다.
ex. 로그레벨로 TRACE로 설정하면 모든 로그레벨에 해당하는 로그가 다 출력됩니다.
일반적으로 로그를 사용할때 log.debug("Debug : "+ log);
이런 형태로 문자열과 변수를 더해서 출력하는 형태인데 이런 코드는 불필요한 리소스를 낭비하게 합니다.(문자열을 더하는 비용은 많은 리소스를 잡아먹습니다.) SLF4J는 이런 단점을 보완하기 위해 아래의 형태로 로그를 구현할 수 있도록 메소드를 제공합니다.
1 | log.debug("Debug : {}", log); |
이런 형태로 사용이 가능하고 로그 레벨이 debug 보다 상위 레벨일때 debug 로그를 출력하지 않도록 처리한다.
이클립스에서는 Log4j, SLF4J 라이브러리별 템플릿을 설정해서 로그 설정하는 코드를 쉽고 빠르게 입력할 수 있게 해준다.
(intellij에서도 live template에 설정해 놓으면 빠르게 사용 가능할듯 합니다.)