JSP와 MVC 패턴 Todo 프로젝트(게시판 CRUD)
MVC 패턴 (Model-View-Controller)
- 소프트웨어 설계 패턴
- 애플리케이션을 세 가지 주요 구성 요소인 모델(Model), 뷰(View), 컨트롤러(Controller)로 나누어 구현하는 방식
- 목적 : 코드의 재사용성과 유지보수성을 높이고, 역할과 책임을 명확히 분리
- 소프트웨어 개발에서 자주 발생하는 문제를 해결하기 위해 검증된 재사용 가능한 솔루션
- 설계 패턴은 객체 지향 설계 원칙을 따르며, 다양한 상황에서 사용될 수 있는 일반적인 템플릿을 제공함.(디자인패턴)
Model, View, Controller의 앞 글자를 딴 것으로, 프로그램을 구성하는 요소들을 모델, 컨트롤, 뷰로 나누어 설계하는 아키텍처 패턴 중의 하나이다.
- Model (모델): 애플리케이션의 데이터와 비즈니스 로직을 관리합니다. 데이터베이스와의 상호작용을 처리하며, 데이터의 상태를 유지합니다.
- View (뷰): 사용자 인터페이스를 담당합니다. 모델의 데이터를 사용자에게 보여주고, 사용자의 입력을 받아들입니다.
- Controller (컨트롤러): 사용자의 입력을 처리하고, 모델과 뷰를 연결합니다. 사용자의 요청을 받아 적절한 모델을 호출하고, 결과를 뷰에 전달합니다.
주요 소프트웨어 설계 패턴
- 생성 패턴 (Creational Patterns): 객체 생성 메커니즘을 제공하여 코드의 유연성을 높입니다.
- 구조 패턴 (Structural Patterns): 클래스와 객체를 조합하여 더 큰 구조를 형성합니다.
- 행위 패턴 (Behavioral Patterns): 객체 간의 상호작용과 책임 분담을 정의합니다.
MVC (Model-View-Controller) 패턴은 1979년에 트리그브 렌스카우그(Trygve Reenskaug)가 제록스 팔로 알토 리서치 센터(Xerox Palo Alto Research Center, PARC)에서 개발한 Smalltalk-80 언어에서 처음 소개되었습니다. 렌스카우그의 원래 목표는 사용자 인터페이스를 설계하는 데 있어 데이터, 비즈니스 로직, 그리고 사용자 인터페이스를 명확히 분리하는 것이었습니다. 이로 인해 시스템을 더 쉽게 이해하고 유지보수할 수 있게 되었습니다. MVC는 구조 패턴과 행위 패턴을 결합한 복합 패턴으로 볼 수 있습니다.
Dynamic Web Project 생성
- MVC 패턴을 활용한 코드 설계
- 필요 라이브러리 확인
커넥션 풀을 사용하기 위한 DataSource 설계와 context.xml 파일 사용
context.xml 파일에 DataSource 설정(HikariCP 사용)
파일 위치 : webapp > META-INF > context.xml
<Context>
<Resource name="jdbc/MyDB"
auth="Container"
type="javax.sql.DataSource"
factory="con.zaxxer.hikari.HikariJNDIFactory"
uniqueResourceName="MyDB"
minimumIdle="5"
maximumPoolSize="10"
connectionTimeout="30000"
idleRimeout="600000"
maxLifetime="1800000"
jdbcUrl="jdbc:mysql://localhost:3306/tb_todo?serverTimezone=Asia/Seoul"
driverClassName="com.mysql.cj.jdbc.Driver"
uername="root"
password="asd123"
/>
</Context>
- Resource name="jdbc/MyDB": JNDI 이름으로, 애플리케이션에서 이 이름을 통해 데이터 소스를 참조합니다.
- auth="Container": 인증 방식으로, 컨테이너에 의해 인증됨을 의미합니다.
- type="javax.sql.DataSource”: 이 리소스가 DataSource 타입임을 명시합니다.
- factory="com.zaxxer.hikari.HikariJNDIFactory”: HikariCP의 JNDI 팩토리 클래스를 사용하여 데이터 소스를 생성합니다.
- uniqueResourceName="MyDB”: HikariCP 설정에 사용되는 고유한 리소스 이름입니다.
- minimumIdle="5": 최소 연결 수를 5로 설정합니다.
- maximumPoolSize="10”: 최대 연결 수를 10으로 설정합니다.
- connectionTimeout="30000”: 연결을 시도할 때 30초(30000 밀리초) 동안 대기합니다.
- idleTimeout="600000”: 유휴 상태의 연결을 10분(600000 밀리초) 동안 유지합니다.
- maxLifetime="1800000”: 연결의 최대 수명을 30분(1800000 밀리초)으로 설정합니다.
- driverClassName="com.mysql.cj.jdbc.Driver": MySQL JDBC 드라이버 클래스 이름입니다.
context.xml 파일을 사용하는 이유
목적 : 자원 공유
- 여러 웹 애플리케이션이 동일한 자원(예: 데이터 소스)을 사용할 때, 이를 중앙에서 관리하고 공유할 수 있습니다.
- 각 웹 애플리케이션이 별도로 설정하지 않고도 동일한 설정을 사용할 수 있어, 설정의 중복을 피할 수 있습니다.
환경 설정 분리
- 애플리케이션 코드와 환경 설정을 분리하여 관리할 수 있습니다.
- 데이터베이스 연결 정보와 같은 환경 설정을 코드에서 분리하여 관리하면, 애플리케이션을 다른 환경(예: 개발, 테스트, 운영)으로 이동할 때 설정만 변경하면 됩니다.
즉, 대규모 애플리케이션에서는 많은 설정이 필요합니다. 이를 중앙에서 관리할 수 있는 방법론이 필요하였고 관리자나 운영자가 설정을 일관되게 관리하고 변경할 수 있는 메커니즘이 제공하기 위한 기술로 발전하게 되었습니다.
context.xml 파일의 역할과 JNDI의 개념
context.xml
- 서버 시작 시 톰캣 같은 서블릿 컨테이너에 의해 로드됩니다. 일반적으로 web.xml 파일보다 먼저 실행이 됩니다.
- 이 파일에 정의된 리소스는 메모리에 객체로 올라가며, JNDI 네임스페이스에 이름으로 등록됩니다.
- 이러한 설정은 각 애플리케이션에서 공유할 수 있도록 전역적으로 설정됩니다.
JNDI (Java Naming and Directory Interface)
- 네이밍 및 디렉토리 서비스를 통해 자원(예: 데이터 소스)을 이름으로 찾고 참조하는 기술입니다.
- 애플리케이션은 JNDI 네임스페이스에 저장된 자원에 접근하여 해당 자원의 객체를 사용할 수 있습니다.
context.xml에 기술된 리소스는 내부적으로 파일을 읽어서 객체를 WAS(Web Application Server)의 메모리에 로드하고, 해당 객체(DataSource)는 JNDI API를 활용하여 JNDI 네임스페이스에 설정된 이름으로 등록됩니다.
이렇게 등록된 객체는 전역적으로 다른 애플리케이션 또는 컴포넌트가 이름을 통해 접근하여 사용할 수 있는 기술입니다.
InitialContext ctx = new InitialContext();
// JNDI 네임스페이스에서 "java:comp/env/jdbc/MyDB" 이름으로 데이터 소스를 찾음
dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/MyDB");
JNDI 이름 규칙
java:comp/env : 표준 JNDI 컨텍스트 루트로, 애플리케이션 환경 항목이 배치되는 기본 네임스페이스입니다.
Resource 이름 : 리소스 이름은 context.xml 파일에서 정의된 이름과 일치해야 합니다.
InitialContext 객체의 역할
InitialContext 객체는 JNDI (Java Naming and Directory Interface) API의 기본 클래스 중 하나로, 애플리케이션이 네이밍 및 디렉토리 서비스를 사용할 수 있도록 해줍니다.
게시판 CRUD
테이블 설계
create database if not exists db_todo2;
use db_todo2;
-- 정규화 1, 2, 3 정규 테이블 설계
-- users 테이블을 생성
create table if not exists users(
id int auto_increment primary key,
username varchar(50) not null,
password varchar(255) not null,
email varchar(100) not null,
created_at timestamp default current_timestamp
);
alter table users add constraint unique(username);
-- todos 테이블 생성
create table if not exists todos(
id int auto_increment primary key,
title varchar(100) not null,
description text,
created_at timestamp default current_timestamp,
due_date date,
completed boolean default false,
user_id int not null,
foreign key(user_id) references users(id)
);
-- 샘플 데이터 삽입
-- users 테이블에 데이터 삽입
INSERT INTO users (username, password, email) VALUES
('홍길동', 'asd123', 'hong@example.com'),
('김철수', 'asd123', 'kim@example.com'),
('이영희', 'asd123', 'lee@example.com');
-- todos 테이블에 데이터 삽입
INSERT INTO todos (user_id, title, description, due_date, completed) VALUES
(1, '할 일 1', '할 일 1에 대한 설명입니다.', '2023-12-31', FALSE),
(1, '할 일 2', '할 일 2에 대한 설명입니다.', '2024-01-15', TRUE),
(2, '할 일 3', '할 일 3에 대한 설명입니다.', '2024-02-28', FALSE),
(3, '할 일 4', '할 일 4에 대한 설명입니다.', '2024-03-10', TRUE);
project - package 설계
DBUtil 설계(class)
package com.tenco.utils;
import java.sql.Connection;
import java.sql.SQLException;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;
public class DBUtil {
// 자바코드에서 context.xml을 당겨 씀.
private static DataSource dataSource;
// 정적 초기화 블록
static {
try {
// initialContext 객체를 생성하여 JNDI API 기술을 통해 존재하는 리소스를 찾는 방법
InitialContext ctx = new InitialContext();
dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/MyDB");
// InitialContext 내의 lookup(바라봄) 메서드를 사용( java:comp/env(root) 와 /jdbc/MyDB(Resource name))
// webapp > META-INF > context.xml 내의 Resource type(DataSource)을 다운캐스팅하여 dataSource에 담음.
} catch (NamingException e) {
e.printStackTrace();
}
}
public static DataSource getDataSource() throws SQLException {
return dataSource;
}
}
user
UserDAO 설계(interface)
package com.tenco.model;
import java.util.List;
public interface UserDAO {
int addUser(UserDTO userDTO);
UserDTO getUserById(int id);
UserDTO getUserByUsername(String username);
List<UserDTO> getAllUsers();
void updateUser(UserDTO user, int principalId);
void deleteUser(int id);
}
UserDTO 설계
package com.tenco.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class UserDTO {
private int id;
private String username;
private String password;
private String email;
private String createdAt;
}
UserDAOImpl 설계(UserDAO 구현 클래스)
package com.tenco.model;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;
import com.tenco.utils.DBUtil;
public class UserDAOImpl implements UserDAO {
// HikariCP(connection pool)의 추상적 개념
private DataSource dataSource;
public UserDAOImpl() {
try {
dataSource = DBUtil.getDataSource();
// DBUtil 클래스의 getDataSource 메서드를 호출하여 반환한 dataSource 값을
// 여기서 선언한 dataSource에 담음.
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 회원가입 쿼리
*/
@Override
public int addUser(UserDTO userDTO) {
int resultCount = 0;
String sql = " INSERT INTO users(username, password, email) VALUES (?, ?, ?) ";
try (Connection conn = dataSource.getConnection()) {
// 트랜잭션 시작
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, userDTO.getUsername());
pstmt.setString(2, userDTO.getPassword());
pstmt.setString(3, userDTO.getEmail());
resultCount = pstmt.executeUpdate();
// 트랜잭션 커밋
conn.commit();
} catch (Exception e) {
conn.rollback();
e.printStackTrace();
} // end of PreparedStatement
} catch (Exception e) {
e.printStackTrace();
} // end of Connection
return resultCount;
}
/**
* user의 id로 사용자 정보 조회 쿼리
*/
@Override
public UserDTO getUserById(int id) {
String sql = " select * from users where id = ? ";
UserDTO userDTO = null;
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
ResultSet rs = pstmt.executeQuery();
// 단일행 ...(다중행이면 while문 사용)
if (rs.next()) {
userDTO = new UserDTO();
userDTO.setId(rs.getInt("id")); // 데이터 추출, 파싱
userDTO.setUsername(rs.getString("username"));
userDTO.setPassword(rs.getString("password"));
userDTO.setEmail(rs.getString("email"));
userDTO.setCreatedAt(rs.getString("created_at"));
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return userDTO;
}
/**
* username으로 사용자 정보 조회
*/
@Override
public UserDTO getUserByUsername(String username) {
String sql = " select * from users where username = ? ";
UserDTO userDTO = null;
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, username);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
userDTO = new UserDTO();
userDTO.setId(rs.getInt("id")); // 데이터 추출, 파싱
userDTO.setUsername(rs.getString("username"));
userDTO.setPassword(rs.getString("password"));
userDTO.setEmail(rs.getString("email"));
userDTO.setCreatedAt(rs.getString("created_at"));
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return userDTO;
}
/**
* 전체 사용자 정보 조회
*/
@Override
public List<UserDTO> getAllUsers() {
String sql = " select * from users ";
// 자료구조를 사용할 때 일단 생성시키자.
// 메모리에 올라가 있어야 넣을 수 있기 때문에 메모리 공간을 띄움.
List<UserDTO> list = new ArrayList<>();
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
UserDTO userDTO = new UserDTO();
userDTO.setId(rs.getInt("id"));
userDTO.setUsername(rs.getString("username"));
userDTO.setPassword(rs.getString("password"));
userDTO.setEmail(rs.getString("email"));
userDTO.setCreatedAt(rs.getString("created_at"));
list.add(userDTO);
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return list;
}
/*
* user id로 사용자 비밀번호와 email 수정 쿼리
*/
@Override
public int updateUser(UserDTO user, int principalId) {
int rowCount = 0;
String sql = " UPDATE users SET password = ?, email = ? WHERE id = ? ";
try (Connection conn = dataSource.getConnection()){
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)){
pstmt.setString(1, user.getPassword());
pstmt.setString(2, user.getEmail());
pstmt.setInt(3, principalId);
rowCount = pstmt.executeUpdate();
conn.commit();
} catch (Exception e) {
conn.rollback();
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return rowCount;
}
/**
* user id로 사용자 데이터 삭제 쿼리
*/
@Override
public int deleteUser(int id) {
int rowCount = 0;
String sql = " DELETE FROM users WHERE id = ? ";
try (Connection conn = dataSource.getConnection()){
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)){
pstmt.setInt(1, id);
rowCount = pstmt.executeUpdate();
conn.commit();
} catch (Exception e) {
conn.rollback();
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return rowCount;
}
}
* 추가 설명*
왜 select 구문에 트랜잭션을 해야 하는가?
1. 임시 데이터 뽑음 2. update 3. select -> insert
==> 하나의 트랜잭션으로 묶을 수 있음.
==> 중간에 update 해서 값이 바뀌면 데이터가 꼬일 수 있음.
==> 잠깐 막아줘야 함.(= 트랜잭션 걸어야 함.)
SELECT에서는 일단 트랜잭션 처리를 하지 말자.
하지만 팬텀리드현상(트랜잭션 내에서 같은 쿼리문을 여러번 수행할 때,
각 쿼리문의 결과가 서로 다른 경우) 발생 가능함.
==> 정합성을 위해서 처리하는 것도 옳은 방법이다.
UserController(서블릿 클래스 상속) - jsp 파일로 넘어가도록 설계
package com.tenco.controller;
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 jakarta.servlet.http.HttpSession;
import java.io.IOException;
import com.tenco.model.UserDAO;
import com.tenco.model.UserDAOImpl;
import com.tenco.model.UserDTO;
// 주소 설계
// http://localhost:8080/mvc/user/
// http://localhost:8080/mvc/user/xxx
// URL mapping -> /user/*로 받을 수 있음
// user 밑으로 들어오는 모든 경로를 받아라는 의미
@WebServlet("/user/*")
public class UserController extends HttpServlet {
private static final long serialVersionUID = 1L;
private UserDAO userDAO; // 포함관계
public UserController() {
super();
}
@Override
public void init() throws ServletException {
userDAO = new UserDAOImpl();
} // 한번만 올라가도록 메모리에 올림.
// GET 방식으로 들어올 때
// http://localhost:8080/mvc/user/signIn -> 로그인 페이지
// http://localhost:8080/mvc/user/signUp -> 회원가입 페이지
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// path variable 받아내는 방법
String action = request.getPathInfo();
System.out.println("action : " + action);
switch (action) {
case "/signIn":
// 로그인 페이지로 보내는 동작 처리
request.getRequestDispatcher("/WEB-INF/views/signIn.jsp").forward(request, response);
break;
case "/signUp":
// 회원가입 페이지로 보내는 동작 처리
request.getRequestDispatcher("/WEB-INF/views/signUp.jsp").forward(request, response);
break;
default:
response.sendError(HttpServletResponse.SC_NOT_FOUND);
break;
}
}
// http://localhost:8080/mvc/views/todoForm.jsp -> servlet 거치지 않고 폴더로 바로 옴.
// 보안 폴더 (URL로 응답 못 받음) => WEB-INF나 META-INF(둘 다 보안 폴더임) 하위로 폴더 옮기기
// 로그인 기능 요청(자원에 요청 -- GET 방식 예외적인 처리_ 보안)
// POST 요청 시 - 로그인 기능 구현, 회원 가입 기능 구현
// POST -> 웹 브라우저의 주소창에 칠 수 없음.
// Talent API, postman => 도구로 사용해야 함.
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String action = request.getPathInfo();
System.out.println("action : " + action);
switch (action) {
case "/signIn":
// signIn 페이지로 이동 시 action에 /signIn이 출력됨.
signIn(request, response);
break;
// signIn 페이지로 이동 시 action에 /signUp이 출력됨.
case "/signUp":
signUp(request, response); //단축키: ctrl + 1 => 메서드 생성
break;
default:
response.sendError(HttpServletResponse.SC_NOT_FOUND);
break;
}
}
/**
* 로그인 처리 기능
* @param request
* @param response
* @throws IOException
*/
private void signIn(HttpServletRequest request, HttpServletResponse response)
throws IOException {
// URL mapping, 인증 검사, 유효성 검사, 서비스 로직, DAO에게 전달 역할, view 호출
String username = request.getParameter("username");
String password = request.getParameter("password");
// 유효성 검사
// .trim().isEmpty(): 공백이 있는 문자열도 빈문자열로 인식
if(username == null || password.trim().isEmpty()) {
// 이름과 비밀번호가 null이면 로그인 페이지로 던짐.
response.sendRedirect("signIn?message=invalid");
return;
}
UserDTO user = userDAO.getUserByUsername(username);// username으로 유저 확인
// 빠른 평가
if(user != null && user.getPassword().equals(password)) {
// 사용자의 세션 메모리에 로그인 정보를 넣음(세션 기반 인증 처리)
HttpSession session = request.getSession();
session.setAttribute("principal", user);
// key 값(principal)은 임의로 설정, 사용자 정보(user) 통으로 넣음.
// 로그인 시 session 생성
// DB에 있는 데이터까지 전부 들어가 있음.
// 로그인 성공 시 자동으로 todoFrom 화면으로 이동 처리
response.sendRedirect("/mvc/todo/todoForm");
System.out.println("로그인 완료");
} else {
response.sendRedirect("signIn?message=invalid"); // 예외 처리
}
// null <-- 회원가입 X(등록되지 않은 사람)
// 비밀번호 != dto.getPassword(); (password 틀림) 추가 기능 생략
}
/**
* 회원 가입 기능
* @param request
* @param response
* @throws IOException
* @throws ServletException
*/
private void signUp(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 인증 검사가 필요 없는 기능(회원가입)
String username = request.getParameter("username");
String password = request.getParameter("password");
String email = request.getParameter("email");
// 방어적 코드 작성(username 확인)
if(username == null || username.trim().isEmpty()) {
// errorMessage 설정
request.setAttribute("errorMessage", "사용자 이름을 입력하시오.");
// 회원가입 페이지로 이동
request.getRequestDispatcher("/WEB-INF/views/signUp.jsp").forward(request, response);
return;
}
// 방어적 코드 작성 (password 확인) - 생략
// 방어적 코드 작성 (email 확인) - 생략
// builder를 통해 UserDTO에서 username, password, email 만 가져옴.
UserDTO userDTO = UserDTO.builder()
.username(username)
.password(password)
.email(email)
.build();
// userDAO 인터페이스의 addUser 메서드에 userDTO를 넣어서
// insert 구문으로 반환될 데이터가 int로 반환됨.
int resultRowCount = userDAO.addUser(userDTO);
System.out.println("resultRowCount : " + resultRowCount);
if(resultRowCount == 1) {
// 로그인 성공
response.sendRedirect("signIn?message=success");
} else {
// 절대경로
//response.sendRedirect("/mvc/user/signIn?message=error");
// 상대경로
// get 방식으로 돌아옴
// 로그인 실패
response.sendRedirect("signIn?message=error");
}
}
}
todo
TodoDAO 설계(interface)
package com.tenco.model;
import java.util.List;
public interface TodoDAO {
// 일정 추가 기능
void addTodo(TodoDTO dto, int principalId);
// Id로 조회
TodoDTO getTodoById(int id);
// 사용자 아이디 기준으로 todoList
List<TodoDTO> getTodosByUserId(int userId);
//전체 select (where 절X)
List<TodoDTO> getAllTodos();
// 일정 수정
void updateTodo(TodoDTO dto, int principalId);
// 일정 삭제
void deleteTodo(int id, int principalId);
}
TodoDTO 설계
package com.tenco.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class TodoDTO {
private int id;
private int userId;
private String title;
private String description;
private String dueDate;
private String completed;// "1", "0"
// completed를 데이터 변환 하는 메서드를 만들자.
public String completedToString() {
// 1이면 완료, 0이면 미완료
return this.completed.equals("1") ? "true" : "false";
}
}
TodoDAOImpl 설계(TodoDAO 구현 클래스)
package com.tenco.model;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;
import com.tenco.utils.DBUtil;
public class TodoDAOImpl implements TodoDAO {
private DataSource dataSource;
public TodoDAOImpl() {
try {
dataSource = DBUtil.getDataSource();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 할 일 추가 쿼리
*/
@Override
public void addTodo(TodoDTO dto, int principalId) {
String sql = " INSERT INTO todos(user_id, title, description, due_date, completed) "
+ " values(?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, principalId); // principalId = user_id
pstmt.setString(2, dto.getTitle());
pstmt.setString(3, dto.getDescription());
pstmt.setString(4, dto.getDueDate());
pstmt.setInt(5, dto.getCompleted() == "true" ? 1 : 0);
// true이면 1, false면 0 (int로 반환)
pstmt.executeUpdate();
conn.commit();
} catch (Exception e) {
e.printStackTrace();
conn.rollback();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* todos table 의 id로 할 일 전체 조회 쿼리
*/
@Override
public TodoDTO getTodoById(int id) {
String sql = " SELECT * FROM todos WHERE id = ? ";
TodoDTO dto = null;
try (Connection conn = dataSource.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, id);
try (ResultSet rs = pstmt.executeQuery()) {
// 할 일 - 한가지(단일행)
if (rs.next()) {
dto = new TodoDTO();
dto.setId(rs.getInt("id"));
dto.setUserId(rs.getInt("user_id"));
dto.setTitle(rs.getString("title"));
dto.setDescription(rs.getString("description"));
dto.setDueDate(rs.getString("due_date"));
dto.setCompleted(rs.getString("completed"));
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return dto;
}
/**
* user_id로 할 일 전체 조회 쿼리
*/
@Override
public List<TodoDTO> getTodosByUserId(int userId) {
String sql = " select * from todos where user_id = ? ";
List<TodoDTO> todos = new ArrayList<>();
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, userId);
ResultSet rs = pstmt.executeQuery();
// 유저 1명 - 여러 개의 할 일(다중행)
while (rs.next()) {
TodoDTO dto = new TodoDTO(); //주의! TodoDTO 객체가 메모리에 올라가야 함.
dto.setId(rs.getInt("id"));
dto.setUserId(rs.getInt("user_id"));
dto.setTitle(rs.getString("title"));
dto.setDescription(rs.getString("description"));
dto.setDueDate(rs.getString("due_date"));
dto.setCompleted(rs.getString("completed"));
todos.add(dto);
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return todos;
}
/**
* 등록된 할 일 전체 조회 쿼리
*/
@Override
public List<TodoDTO> getAllTodos() {
String sql = " select * from todos ";
// 데이터 타입으로 ArrayList가 아닌 List를 쓴 이유
// ==> 인터페이스(구현 클래스들을(ArrayList, LinkedList, vector 등) 선택해서 활용할 수 있음.)
List<TodoDTO> todos = new ArrayList<>();
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
TodoDTO dto = new TodoDTO();
dto.setId(rs.getInt("id"));
dto.setUserId(rs.getInt("user_id"));
dto.setTitle(rs.getString("title"));
dto.setDescription(rs.getString("description"));
dto.setDueDate(rs.getString("due_date"));
dto.setCompleted(rs.getString("completed"));
todos.add(dto);
}
} catch (Exception e) {
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
return todos;
}
/**
* todo의 id와 user_id로 할 일 수정 쿼리
*/
@Override
public void updateTodo(TodoDTO dto, int principalId) {
// select <-- 있는지 없는지 확인 과정 필요
String sql = " UPDATE todos SET title = ?, description = ?, "
+ " due_date = ?, completed =? WHERE id = ? and user_id = ? ";
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, dto.getTitle());
pstmt.setString(2, dto.getDescription());
pstmt.setString(3, dto.getDueDate());
pstmt.setBoolean(4, Boolean.parseBoolean(dto.getCompleted()));
// int 및 Boolean으로 반환 가능
pstmt.setInt(5, dto.getId());
pstmt.setInt(6, principalId);
pstmt.executeUpdate();
conn.commit();
} catch (Exception e) {
conn.rollback();
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* todo의 id와 user_id로 할 일 삭제 쿼리
* id - todos PK
* principalId - 세션 ID
*/
@Override
public void deleteTodo(int id, int principalId) {
// principalId = user_id (users의 id인 user_id를 가져왔다고 생각하기)
String sql = " DELETE FROM todos where id = ? and user_id = ?";
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
pstmt.setInt(2, principalId);
pstmt.executeUpdate();
conn.commit();
} catch (Exception e) {
conn.rollback();
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
TodoController(서블릿 클래스 상속) - jsp 파일로 넘어가도록 설계
package com.tenco.controller;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import com.tenco.model.TodoDAO;
import com.tenco.model.TodoDAOImpl;
import com.tenco.model.TodoDTO;
import com.tenco.model.UserDTO;
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 jakarta.servlet.http.HttpSession;
// .../mvc/todo/xxx
@WebServlet("/todo/*")
public class TodoController extends HttpServlet {
private static final long serialVersionUID = 1L;
private TodoDAO todoDAO;
public TodoController() {
todoDAO = new TodoDAOImpl(); // 업캐스팅
}
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String action = request.getPathInfo();
// 로그인한 사용자만 접근을 허용하도록 설계
HttpSession session = request.getSession();
// JsessionID + principal 활용하기
UserDTO principal = (UserDTO) session.getAttribute("principal");
// 로그인 할 때 생성된 session(setAttribute)에 존재하는 object를 끄집어냄.
// "principal"의 key 값에는 user 정보가 전부 담겨 있음.
// UserDTO에 담겨서(반환되서) principal 변수에 담김.
// 인증 검사
if (principal == null) {
// 로그인을 안한 상태면 로그인 페이지로 돌려보냄.
response.sendRedirect("/mvc/user/signIn?message=invalid");// 예외 처리
return;
}
System.out.println("action : " + action);
switch (action) {
// http://localhost:8080/mvc/todo/todoForm
case "/todoForm":
todoFormPage(request, response);
break;
// http://localhost:8080/mvc/todo/list
case "/list":
// user의 id를 받아와야 하기 때문
todoListPage(request, response, principal.getId());
break;
// http://localhost:8080/mvc/todo/detail
case "/detail":
todoDetailPage(request, response, principal.getId());
break;
case "/delete":
deleteTodo(request, response, principal.getId());
break;
default:
response.sendError(HttpServletResponse.SC_NOT_FOUND); // 404 던짐
break;
}
}
/**
* todo 작성 페이지로 이동
*
* @param request
* @param response
* @throws IOException
* @throws ServletException
*/
private void todoFormPage(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
request.getRequestDispatcher("/WEB-INF/views/todoForm.jsp").forward(request, response); // 예외 처리
}
// 필터 : WAS 에 들어오기 전에 정상적인 사용자인지 걸러내는 것
/**
* 사용자별 todo 리스트 페이지로 이동
*
* @param request
* @param response
* @param principalId
* @throws IOException
* @throws ServletException
*/
private void todoListPage(HttpServletRequest request, HttpServletResponse response, int principalId)
throws IOException, ServletException {
// request.getPathInfo() --> URL 요청에 올 때 데이터 추출
// request.getParameter() --> URL 요청에 올 때 데이터 추출
// request.getAttribute() --> 뷰를 내릴 속성에 값을 뽑아서 뷰로 내릴 때
List<TodoDTO> list = todoDAO.getTodosByUserId(principalId);
request.setAttribute("list", list);// list 값을 넣어줌.
// DB에서 조회해서 데이터를 담아서 던질 예정
// todoList.jsp 페이지로 내부에서 이동 처리
request.getRequestDispatcher("/WEB-INF/views/todoList.jsp").forward(request, response);
}
/**
* 상세보기 화면
*
* @param request
* @param response
* List에서 상세 페이지로 넘어옴.
* @throws IOException
*/
// http://localhost:8080/mvc/todo/detail?id=2;
private void todoDetailPage(HttpServletRequest request, HttpServletResponse response, int principalId) throws IOException {
// detail?id=2
try {
// todo - PK (여러개) => 1, 3, 5(야스오)
// todo - PK => 2, 4, 6(홍길동)
int todoId = Integer.parseInt(request.getParameter("id"));
// "id" 값을 int로 변경하여 todoId에 담음
// 해당하는 todo 값을 select 한 후에 올려야 함.
TodoDTO dto = todoDAO.getTodoById(todoId);
// todoDAO의 getTodoById 메서드에 int로 변경된 id 값이 담긴 todoId를 넣음
// todoId를 넣음 (= where 문으로 필터링 된 select 문이 돌아가서 모든 정보를 출력해서
// TodoDTO에 담긴(반환한) 값이 dto에 담김.
// dto에 담긴 값인 UserId와 user 값을 가지고 있는 principalId 값이 동일할 때
if(dto.getUserId() == principalId) {
// 상세보기 화면으로 이동 처리
// 뿌릴 데이터가 필요함.(dto를 담아서 뿌려줌)
request.setAttribute("todo", dto);
request.getRequestDispatcher("/WEB-INF/views/todoDetail.jsp").forward(request, response);
} else {
// 권한이 없습니다. or 잘못된 접근입니다.
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter(); //OutputStream
out.println("<script> alert('권한이 없습니다.'); history.back(); </script>"); // 뒤로 돌아가기
}
// * get 방식은 스택이 아래로 점점 쌓여서 뒤로가기 하면 뒤로 가짐. (하지만 post는 안 됨)
} catch (Exception e) {
response.sendRedirect(request.getContextPath() + "/todo/list?error=invalid");
}
}
/**
* todo 삭제 기능
*
* @param request
* @param response
* @param principalId
* @throws IOException
*/
private void deleteTodo(HttpServletRequest request, HttpServletResponse response, int principalId) throws IOException {
try {
int todoId = Integer.parseInt(request.getParameter("id"));
todoDAO.deleteTodo(todoId, principalId);
} catch (Exception e) {
// 삭제 실패 시 error 메시지
response.sendRedirect(request.getContextPath() + "/todo/list?error=invalid");
}
// todo 삭제 후 list 화면으로 이동
response.sendRedirect(request.getContextPath() + "/todo/list");
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession();
UserDTO principal = (UserDTO) session.getAttribute("principal");
// 인증 검사
// principal == null 이라면 -> 로그인 페이지로 이동 처리
if (principal == null) {
response.sendRedirect(request.getContextPath() + "/user/signIn?error=invalid");
return;
}
String action = request.getPathInfo();
System.out.println("action : " + action);
switch (action) {
case "/add":
addTodo(request, response, principal.getId());
break; // break 없으면 밑에 코드 바로 실행됨.
case "/update":
updateTodo(request, response, principal.getId());
break;
default:
response.sendError(HttpServletResponse.SC_NOT_FOUND); // 404 던짐
break;
}
}
/**
* 세션별 사용자 todo 등록
*
* @param request
* @param response
* @param principalId : 세션에 담겨 있는 UserId 값
* @throws IOException
*/
private void addTodo(HttpServletRequest request, HttpServletResponse response, int principalId) throws IOException {
String title = request.getParameter("title");
String description = request.getParameter("description");
String dueDate = request.getParameter("dueDate");
// checkbox는 여러개 선택 가능한 태그 : String[] 배열로 선언했음
// 이번에 checkbox는 하나만 사용중
// 체크박스가 선택되지 않았으면 null을 반환하고 체크가 되어있다면 on(문자열)을 반환함
boolean completed = "on".equalsIgnoreCase(request.getParameter("completed"));// on 과 같다면 true
TodoDTO dto = TodoDTO
.builder()
.userId(principalId)
.title(title)
.description(description)
.dueDate(dueDate)
.completed(String.valueOf(completed))
// String으로 변환
.build();
todoDAO.addTodo(dto, principalId);// 추가 기능
response.sendRedirect(request.getContextPath() + "/todo/list"); // 추가된 후 list 페이지로 이동
}
/**
* todo 수정 기능
*
* @param request
* @param response
* @param principalId - 세션 ID 값
* @throws IOException
*/
private void updateTodo(HttpServletRequest request, HttpServletResponse response, int principalId) throws IOException {
try {
int todoId = Integer.parseInt(request.getParameter("id"));
String title = request.getParameter("title");
String description = request.getParameter("description");
String dueDate = request.getParameter("dueDate");
boolean completed = "on".equalsIgnoreCase(request.getParameter("completed"));// on 과 같다면 true
System.out.println("completed : " + completed);
TodoDTO dto = TodoDTO
.builder()
.id(todoId)
.userId(principalId)
.title(title)
.description(description)
.dueDate(dueDate)
.completed(String.valueOf(completed)).build();
todoDAO.updateTodo(dto, principalId); // 수정 기능
} catch (Exception e) {
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script> alert('잘못된 요청입니다.'); history.back(); </script>");
}
response.sendRedirect(request.getContextPath() + "/todo/list");
}
}
jsp 파일(HTML)
signIn
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(120deg, #f6d365 0%, #fda085 100%);
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #ffffff;
padding: 30px 40px;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
animation: fadeIn 0.5s ease-in-out;
}
h1 {
margin-bottom: 30px;
color: #333333;
}
.form-group {
display: flex;
flex-direction: column;
width: 100%;
margin-bottom: 20px;
}
label {
margin-bottom: 8px;
color: #555555;
}
input[type="text"], input[type="password"] {
padding: 12px;
border: 1px solid #dddddd;
border-radius: 8px;
width: 100%;
transition: border-color 0.3s;
}
input[type="text"]:focus, input[type="password"]:focus {
border-color: #007bff;
outline: none;
}
.btn {
padding: 12px;
border: none;
border-radius: 8px;
background-color: #007bff;
color: #ffffff;
font-size: 16px;
cursor: pointer;
width: 100%;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #0056b3;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
</head>
<body>
<div class="container">
<h1>로그인</h1>
<form action="${pageContext.request.contextPath}/user/signIn" method="post">
<div class="form-group">
<label for="username">아이디</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">로그인</button>
</form>
</div>
</body>
</html>
signUp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>회원 가입</title>
<link rel="stylesheet" type="text/css" href="css/styles.css">
</head>
<body>
<!-- http://localhost:8080/mvc/user/signUp -->
<h2>회원가입</h2>
<%
// String errorMessage = (String) request.getAttribute("message");
// 쿼리 스트링에서 뽑아야 되는 메서드는 getParameter로 뽑아야 함.
String errorMessage = (String) request.getParameter("message");
if(errorMessage != null){
%>
<p style="color: red"> <%=errorMessage%></p>
<% } %>
<!-- 절대 경로 사용해보기 -->
<form action="/mvc/user/signUp" method="post">
<label for="username">사용자 이름 :</label>
<input type="text" id="username" name="username" value="야스오1">
<label for="password">비밀번호 :</label>
<input type="password" id="password" name="password" value="1234">
<label for="email">이메일 :</label>
<input type="text" id="email" name="email" value="abc@nate.com">
<button type="submit">회원가입</button>
</form>
</body>
</html>
todoForm
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>새 할 일 추가</title>
</head>
<body>
<h1>ToDo Page</h1>
<%-- http://localhost:8080/mvc/todo/add --%>
<form action="add" method="post">
<label for="title">제목 : </label>
<input type="text" id="title" name="title" value="제목칸">
<br><br>
<label for="description">설명 : </label>
<textarea rows="30" cols="50" id="description" name="description">
설명칸
</textarea>
<br><br>
<label for="dueDate">마감기한 : </label>
<input type="date" id="dueDate" name="dueDate" value="2024-07-11">
<br><br>
<label for="completed">완료 여부 : </label>
<input type="checkbox" id="completed" name="completed" >
<br><br>
<button type="submit">추가</button>
</form>
<br><br>
<a href="list">목록으로 돌아가기</a>
</body>
</html>
todoList
<%@page import="java.util.Date"%>
<%@page import="java.util.ArrayList"%>
<%@page import="com.tenco.model.TodoDTO"%>
<%@page import="java.util.List"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>할 일 목록</title>
<link rel="stylesheet" type="text/css" href="../css/styles.css">
</head>
<body>
<!-- http://localhost:8080/mvc/todo/list -->
<%
List<TodoDTO> todoList = (List<TodoDTO>) request.getAttribute("list");
if(todoList != null && !todoList.isEmpty()) {
%>
<h2>할 일 목록</h2>
<a href="todoForm">할 일 추가</a>
<table border="1">
<tr>
<th>제목</th>
<th>설명</th>
<th>마감일</th>
<th>완료 여부</th>
<th>(액션-버튼)</th>
</tr>
<!-- list -> 여러번 반복해야 함. -->
<%
for(TodoDTO todo : todoList){
%>
<tr>
<td><%=todo.getTitle()%></td>
<td><%=todo.getDescription() %></td>
<td><%=todo.getDueDate()%></td>
<td><%=todo.completedToString() == "true" ? "완료" : "미완료"%></td>
<td>
<a href="detail?id=<%=todo.getId()%>">상세보기</a>
<form action="delete" method="get">
<input type="hidden" name="id" value="<%=todo.getId() %>">
<button type="submit">삭제</button>
</form>
</td>
</tr>
<% } %>
</table>
<% } else { %>
<hr>
<p>등록된 할 일이 없습니다.</p>
<% } %>
</body>
</html>
todoDetail
<%@page import="com.tenco.model.TodoDTO"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>상세 보기 화면</title>
</head>
<body>
<%
TodoDTO todo = (TodoDTO) request.getAttribute("todo");
if(todo != null) {
%>
<p>제목 : <%=todo.getTitle() %></p><br>
<p>설명 : <%=todo.getDescription() %></p><br>
<p>마감일 : <%=todo.getDueDate() %></p><br>
<p>완료여부 : <%=todo.completedToString() == "true" ? "완료" : "미완료"%></p><br>
<hr><br>
<form action="update" method="post">
<input type="hidden" name="id" value="<%=todo.getId()%>">
<label for="title">제목 : </label>
<input type="text" id="title" name="title" value="<%=todo.getTitle()%>">
<br>
<label for="description">설명 : </label>
<input type="text" id="description" name="description" value="<%=todo.getDescription()%>">
<br>
<label for="dueDate">마감일 : </label>
<input type="date" id="dueDate" name="dueDate" value="<%=todo.getDueDate()%>">
<br>
<label for="completed">완료여부 : </label>
<input type="checkbox" id="completed" name="completed" <%=todo.completedToString() == "true" ? "checked" : ""%>>
<br>
<button type="submit">수정</button>
</form>
<%
} else {
out.print("<p>정보를 불러오는 데 실패</p>");
}
%>
</body>
</html>