[인프런/스프링 MVC 1편] 3. 서블릿, JSP, MVC 패턴
1. 회원관리 웹 애플리케이션 만들기 전 요구사항
회원 정보
이름: username
나이: age
기능 요구사항
- 회원 저장
- 회원 목록 조회
회원 도메인 모델 (회원 객체)
- id는 Member를 회원 저장소에 저장하면 회원 저장소가 save 메소드를 통해 할당한다.
package hello.servlet.domain.member;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class Member {
private Long id;
private String username;
private int age;
public Member() {
}
public Member(String username, int age) {
this.username = username;
this.age = age;
}
}
회원 저장소
- 싱글톤 패턴 적용 (스프링을 사용하면 스프링 빈으로 등록하면 되지만, 지금은 스프링 없이 순수 서블릿으로 구현해보는 것)
- 싱글톤 패턴은 객체를 하나만 생성하므로 생성자를 private 접근자로 막음 (memberRepository.getInstance()로 객체 조회)
package hello.servlet.domain.member;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
*/
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>(); // static 사용 (MemberRepository 객체가 많이 생성되어도 얘는 한번만 생성
private static long sequence = 0L; // static 사용
// 싱글턴으로 생성 (스프링 없이 순수 서블릿만으로 구현하기 위해)
private static final MemberRepository instance = new MemberRepository();
// 무조건 얘로 조회
public static MemberRepository getInstance() {
return instance;
}
// 생성자로 생성못하게 private으로 생성자를 막음 (싱글톤은 객체를 단 하나만 생성해서 공유해야 하므로)
private MemberRepository() {
}
// 회원 저장 메소드
public Member save(Member member) {
// 시퀀스를 늘리면서 아이디 설정
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
// 회원 검색 메소드
public Member findById(Long id) {
return store.get(id);
}
// 모든 값 꺼내기
public List<Member> findAll() {
return new ArrayList<>(store.values()); // store에 있는 값에 접근할 수 없도록 하기 위해 new ArrayList
}
//store 비우기
public void clearStore() {
store.clear();
}
}
회원 저장소 테스트 코드
- test 폴더 -> java -> hello.servlet.doman.member
package hello.servlet.domain.member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
public class MemberRepositoryTest {
// new로 불러올 수 없음 (싱글톤이니까)
MemberRepository memberRepository = MemberRepository.getInstance();
// 테스트가 끝날 때마다 테스트 초기화
@AfterEach
void afterEach() {
memberRepository.clearStore();
}
@Test
void save() { // save 메소드 테스트
// given (이런게 주어졌을 때)
Member member = new Member("hello", 20);
// when (이런걸 실행했을 때)
Member savedMember = memberRepository.save(member); // save 메소드 실행했을 때
// then (결과가 이렇게 되어야 함)
Member findMember = memberRepository.findById(savedMember.getId());
assertThat(findMember).isEqualTo(savedMember); // 찾은 멤버가 저장된 멤버와 같아야 한다.
}
@Test
void findAll() {
// given
Member member1 = new Member("member1", 20);
Member member2 = new Member("member2", 30);
memberRepository.save(member1);
memberRepository.save(member2);
// when
List<Member> result = memberRepository.findAll(); // findAll() 메소드 실행했을 때
// then
assertThat(result.size()).isEqualTo(2); // 결과값의 크기가 2가 맞는지 (alt+enter -> static import 만듦)
assertThat(result).contains(member1, member2); // 결과가 member1, member2를 포함하고 있는지
}
}
2. 서블릿으로 회원관리 웹 애플리케이션 만들기
2-1. 서블릿으로 회원등록 HTML 폼 생성
* MemberFormServlet - 회원등록 폼
- 단순하게 회원정보 (이름, 나이)를 입력할 수 있는 HTML Form 만들어서 응답
- 자바 코드로 HTML을 제공해야 함
- 실행 : http://localhost:8080/servlet/members/new-form
package hello.servlet.web.servlet;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {
// 얘를 사용하려면 객체가 있어야 하는데 싱글톤 -> getInstance()로 불러와야 함
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// html을 응답으로 받아야 하니까
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
// HTML을 자바코드로 다 작성해야 함 -> 서블릿의 단점
PrintWriter w = response.getWriter();
w.write("<!DOCTYPE html>\n" +
"<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>Title</title>\n" +
"</head>\n" +
"<body>\n" +
"<form action=\"/servlet/members/save\" method=\"post\">\n" +
" username: <input type=\"text\" name=\"username\" />\n" +
" age: <input type=\"text\" name=\"age\" />\n" +
" <button type=\"submit\">전송</button>\n" +
"</form>\n" +
"</body>\n" +
"</html>\n");
}
}
2-2. 서블릿으로 회원 데이터 실제 저장하는 기능
* MemberSaveServlet - 회원 저장
- HTML Form에서 데이터 입력하고 전송 누르면 실제 회원 데이터 저장
- 작동 순서
- HTML 폼에서 보낸 데이터 읽음 (이름, 나이)
- 입력된 데이터로 Member 객체 생성
- MemberRepository의 save 메소드를 통해 Member객체를 MemberRepository에 저장
- Member 객체를 사용해서 결과 화면용 HTML을 동적으로 만들어서 응답
- 실행 : http://localhost:8080/servlet/members/save
package hello.servlet.web.servlet;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class MemberSaveServlet extends HttpServlet {
MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// html 폼에서 보낸 데이터 읽기
System.out.println("MemberSaveServlet.service");
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
// 파라미터(폼)에 입력된 데이터로 Member객체 만들고, MemberRepository에 회원 데이터 저장
Member member = new Member(username, age);
memberRepository.save(member);
// 결과화면으로 html을 동적으로 만들어 응답보내기
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
PrintWriter w = response.getWriter();
w.write("<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
"</head>\n" +
"<body>\n" +
"성공\n" +
"<ul>\n" +
" <li>id="+member.getId()+"</li>\n" +
" <li>username="+member.getUsername()+"</li>\n" +
" <li>age="+member.getAge()+"</li>\n" +
"</ul>\n" +
"<a href=\"/index.html\">메인</a>\n" +
"</body>\n" +
"</html>");
}
}
2-3. 저장된 회원 목록 조회하는 기능
* MemberListServlet - 회원 목록
- 작동 순서
- MemberRepository의 findAll() 메소드를 통해 모든 회원 조회
- for문을 통해 회원 목록 HTML을 동적으로 생성하여 응답
- 실행 : http://localhost:8080/servlet/members
package hello.servlet.web.servlet;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {
MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// findAll() 메소드로 회원목록 조회
List<Member> members = memberRepository.findAll();
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
PrintWriter w = response.getWriter();
w.write("<html>");
w.write("<head>");
w.write(" <meta charset=\"UTF-8\">");
w.write(" <title>Title</title>");
w.write("</head>");
w.write("<body>");
w.write("<a href=\"/index.html\">메인</a>");
w.write("<table>");
w.write(" <thead>");
w.write(" <th>id</th>");
w.write(" <th>username</th>");
w.write(" <th>age</th>");
w.write(" </thead>");
w.write(" <tbody>");
for (Member member : members) {
w.write(" <tr>");
w.write(" <td>" + member.getId() + "</td>");
w.write(" <td>" + member.getUsername() + "</td>");
w.write(" <td>" + member.getAge() + "</td>");
w.write(" </tr>");
}
w.write(" </tbody>");
w.write("</table>");
w.write("</body>");
w.write("</html>");
}
}
===> 서블릿과 자바코드만으로 HTML을 동적으로 만들어본 것
-> 매우 비효율적
-> 템플릿 엔진 등장 (JSP, Thymleaf, Freemarker, Velocity 등)
3. JSP로 회원관리 웹 애플리케이션 만들기
( * 인텔리제이 무료버전은 JSP 편의 기능 X -> 일일이 작성, 색깔 표현 없음 ㅠ )
3-1. JSP 라이브러리 추가 -> Gradle refresh
build.gradle 의 dependencies (스프링부트 3.0 이상)
//JSP 추가 시작
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'jakarta.servlet:jakarta.servlet-api' //스프링부트 3.0 이상
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api' //스프링부트 3.0 이상
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl' //스프링부트 3.0 이상
//JSP 추가 끝
3-2. 회원등록 폼 JSP
* new-form.jsp
- 첫 줄에 JSP 문서라는 문장 삽입
( <%@ page contentType="text/html;charset=UTF-8" language="java" %> ) - 첫 줄 제외하고는 HTML과 똑같음.
( JSP는 서버 내부에서 서블릿으로 변환 ) - 실행 : http://localhost:8080/jsp/members/new-form.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<form action="/jsp/members/save.jsp" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
3-3. 회원 저장 JSP
* save.jsp
- JSP는 자바 코드를 그대로 다 사용할 수 있다
- 서블릿과 동일한 로직 (단, 자바코드를 <% %> 안에 넣어주고, import 필요)
- 로직 -> 자바코드
뷰(응답) -> HTML 코드 - request, response 는 바로 사용 가능 (jsp도 어차피 서블릿으로 변경되어서 실행되기 때문)
- 자바의 import문
<%@ page import="hello.servlet.domain.member.MemberRepository" %> - 자바 코드 입력
<% ~~ %> - 자바 코드 출력
<%= ~~ %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%
// 서블릿과 동일한 로직 (단, 자바 객체 import 필요)
// request, response 는 그냥 사용 가능 (jsp도 어차피 서블릿으로 변경되어서 실행되기 때문)
MemberRepository memberRepository = MemberRepository.getInstance();
// html 폼에서 보낸 데이터 읽기
System.out.println("MemberSaveServlet.service");
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
// 파라미터(폼)에 입력된 데이터로 Member객체 만들고, MemberRepository에 회원 데이터 저장
Member member = new Member(username, age);
memberRepository.save(member);
%>
<html>
<head>
<title>Title</title>
</head>
<body>
성공
<ul>
<li>id=<%=member.getId()%></li>
<li>username=<%=member.getUsername()%></li>
<li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
3-4. 회원 목록 JSP
* members.jsp
- List도 import 필요
- 자바 코드로 회원 저장소에 있는 회원목록 조회 -> HTML 코드로 출력
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.util.List" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%
MemberRepository memberRepository = MemberRepository.getInstance();
List<Member> members = memberRepository.findAll();
%>
<html>
<head>
<title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
<thead>
<th>id</th>
<th>username</th>
<th>age</th>
</thead>
<tbody>
<%
for (Member member : members) {
out.write(" <tr>");
out.write(" <td>" + member.getId() + "</td>");
out.write(" <td>" + member.getUsername() + "</td>");
out.write(" <td>" + member.getAge() + "</td>");
out.write(" </tr>");
}
%>
</tbody>
</table>
</body>
</html>
** 서블릿과 JSP의 한계
- 서블릿 : 뷰(응답) 화면을 위한 HTML 코드를 자바 코드로 일일이 구현해야 해서 지저분하고 복잡
- JSP : 응답으로 보내는 HTML은 간편하게 작성할 수 있게 됨 + 동적으로 변경 필요한 부분에만 자바 코드 적용
비즈니스 로직을 위한 자바코드 + 뷰(응답) 화면을 위한 HTML 코드가 한 페이지에 모두 적혀있어서 매우 복잡
---> MVC 패턴 등장
4. MVC 패턴
4-1. 개요
하나의 서블릿, JSP로 처리하던 것을 컨트롤러, 뷰라는 영역으로 서로 역할을 나눈 것
컨트롤러 (서블릿) : HTTP 요청을 받아서 파라미터 검증, 비즈니스 로직 실행 , 뷰에 전달할 결과 데이터를 조회해서 모델에 담음
모델 : 뷰에 출력할 데이터를 담음.
뷰 (JSP) : 모델에 담겨있는 데이터를 사용해서 화면에 구현 - HTML 생성하는 부분
Model 1 : JSP에서 컨트롤러, 뷰의 역할을 모두 함 Model 2 : 컨트롤러(Servelt), 뷰(JSP) 구분
💡 참고
DAO : 데이터 접근 객체 (데이터베이스에 접근하는 역할)
DTO : 데이터 전송 객체 (계층간 데이터 전송할 때)
4-2. MVC 패턴 적용
* Model은 HttpServletRequest 객체 사용. request는 내부에 저장소 가지고 있음
1) 회원 등록
(1) 회원 등록 폼 - 컨트롤러
* MvcMemberFormServlet
- dispatcher.forward() : 다른 서블릿이나 JSP로 이동할 수 있는 기능. 서버 내부에서 다시 호출 발생
- redirect vs forward
- redirect : 실제 클라이언트(웹 브라우저)에 응답이 나갔다가, 클라이언트가 redirect 경로로 다시 요청
-> 클라이언트가 인지할 수 있고, URL 경로도 변경 - forward : 서버 내부에서 일어나는 호출. 메소드 호출하듯이 클라이언트까지 가지 않고 내부에서만 이동.
-> 클라이언트가 인지 X, URL 경로 변경 X
- redirect : 실제 클라이언트(웹 브라우저)에 응답이 나갔다가, 클라이언트가 redirect 경로로 다시 요청
- /WEB-INF : 외부에서 직접 경로를 입력하여 JSP를 호출할 수 X -> 항상 컨트롤러를 통해 JSP 호출
package hello.servlet.web.servletmvc;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 비즈니스 로직은 없지만 컨트롤러를 무조건 거치기 때문에 컨트롤러 -> 뷰로 이동하는 코드 구현
// 뷰의 위치
String viewPath = "/WEB-INF/views/new-form.jsp";
// 컨트롤러에서 뷰로 이동할 때 사용 (이 경로로 이동할거야)
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
// 이걸 호출하면 서블릿에서 jsp를 찾아서 호출 (메소드 호출하듯이, 클라이언트까지 가지 않고 내부 호출이기 때문에 url이 바뀌지 않음)
dispatcher.forward(request, response);
}
}
(2) 회원 등록 폼 - 뷰
* /WEB-INF/views/new-form.jsp
- action : 절대경로(/로 시작)가 아니라 상대경로(/로 시작X)
-> 현재 경로 + save 로 호출
결과 : /servlet-mvc/members/save - 실행 : http://localhost:8080/servlet-mvc/members/new-form
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<!-- 상대경로 사용, [현재 URL이 속한 계층 경로 + /save] -->
<form action="save" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
2) 회원 저장
(1) 회원 저장 - 컨트롤러
* MvcMemberSaveServlet
- HttpServletRequest를 Model로 사용
- 컨트롤러 역할 수행
=> 요청 파라미터 받음, 비즈니스 로직 실행(호출), Model에 데이터 담음, 컨트롤러에서 뷰로 이동
package hello.servlet.web.servletmvc;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemeberSaveServlet extends HttpServlet {
MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 요청 파라미터 받음
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
// 비즈니스 로직 실행(호출)
Member member = new Member(username, age);
memberRepository.save(member);
// Model에 데이터 보관
request.setAttribute("member", member);
// 컨트롤러에서 뷰로 이동
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
(2) 회원 저장 - 뷰
* /WEB-INF/views/save-result.jsp
- request의 attribute에 담긴 데이터 편리하게 조회 -> ${ } 문법 (프로퍼티 접근법)
- 실행 : http://localhost:8080/servlet-mvc/members/save
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
성공
<ul>
<li>id=${member.id}</li>
<li>username=${member.username}</li>
<li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
3) 회원 목록 조회
(1) 회원 목록 - 컨트롤러
* MvcMemberListServlet
package hello.servlet.web.servletmvc;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@WebServlet(name = "mvcMemberListServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {
MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 비즈니스 로직 실행(호출)
List<Member> members = memberRepository.findAll();
// 모델에 데이터 담기
request.setAttribute("members", members);
// 컨트롤러에서 뷰로 이동
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
(2) 회원 목록 - 뷰
* /WEB-INF/views/members.jsp
- model에 담아둔 members데이터를 JSP가 제공하는 taglib 기능을 사용해서 반복 출력
- 실행 : http://localhost:8080/servlet-mvc/members
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<html>
<head>
<title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
<thead>
<th>id</th>
<th>username</th>
<th>age</th>
</thead>
<tbody>
<c:forEach var="item" items="${members}">
<tr>
<td>${item.id}</td>
<td>${item.username}</td>
<td>${item.age}</td>
</tr>
</c:forEach>
</tbody>
</table>
</body>
</html>
4-3. MVC 패턴의 한계
1) MVC 패턴 장점
컨트롤러 - 컨트롤러의 역할만 수행
뷰 - 뷰의 역할만 수행, 코드 깔끔
2) MVC 패턴 단점
(1) 포워드 중복
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
(2) ViewPath 중복
String viewPath = "/WEB-INF/views/new-form.jsp";
prefix : /WEB-INF/viewssuffix : .jsp그리고 jsp가 아닌 다른 뷰로 변경한다면 전체 코드 다 변경해야 함
(3) 사용하지 않는 코드
특히 response는 사용되지 않음
=> 공통 처리 어려움
메소드로 따로 만든다고 해도, 해당 메소드를 항상 호출해야 함
=> 컨트롤러 호출 전에 먼저 공통 기능 처리 => 프론트 컨트롤러(Front Controller) 패턴 도입
(≠ 필터. 필터는 정해진 대로 전처리 기능을 수행해야 하지만, 프론트 컨트롤러는 컨트롤러처럼 다 조작할 수도 있음)