그동안 싸피에서 백엔드와 DB를 학습하면서 로그인, 회원가입을 진행하고 관련 정보들을 DB에서 관리하는 연습들을 조금 했었다. 사실 지금은 알고리즘 과정 중이기도 하고, DB랑 백엔드 할 때도 로그인과 회원가입은 마스터하지 못하고 넘어왔던 탓에 기억은 가물가물하다..ㅎㅎ
그래서!!
앞으로의 1, 2학기 프로젝트에서 많이 써먹을 것이기 때문에 다시 한 번 짚고 넘어가자는 의미로 회원 가입 및 로그인 기능을 다시 차근차근 구현해보며 연습해보고자 한다. + 그동안은 아이디와 비밀번호를 그대로 저장해버렸는데, 사실 이렇게 했다가는 개인정보가 탈탈 털리고 말 것이다. 따라서 암호화 기능도 공부해서 구현보고자 한다!
(이 프로젝트는 저의 혼자 힘이 아닌 싸피의 같은 반 🌟한프로🌟님의 도움으로 진행되었습니다. 그.는.신.이.야. 한프로가 없었다면 이 프로젝트도 없었을 겁니다.)
1. encoding UTF-8로 바꿔주기 (Workspace, CSS, HTML, JSP, XML 전부!)
2. 눈의 피로도와 간zi를 생각해서 dark 모드로 바꿔주기ㅎㅎ😜
그리고 본격 프로젝트 세팅 하기 전에 필요한 파일들 download 해두기~
1. Tomcat
=> 나는 eclipse 2018 버전에 맞게 tomcat9 버전의 64-bit Windows zip으로 받았다. (저장 위치는 상관 x) https://tomcat.apache.org/download-90.cgi 💡tomcat이란? 웹 어플리케이션 서버(Web Application Server, WAS) 중 하나. 요청이 오면 알맞은 프로그램을 실행하여 응답을 만들고 제공하는 "서버"이다.
2. MySQL Connector Java
=> jar파일 다운받기! https://mvnrepository.com/artifact/mysql/mysql-connector-java/8.0.21 💡MySQL Connector란? JDBC(Java Database Connectivity)로서, 말 그대로 java와 DBMS인 MySQL을 연결해 주는 역할을 한다. java에서 DBMS를 쓰기 위한 코드를 DBMS 종류마다 다르게 구현해줄 필요 없이, java에서 JDBC를 사용하는 방법만 알면 모든 종류의 DBMS와 연결해서 사용할 수 있다.
1) 하단에 있는 Servers에서 (hoxy 없다면! Window > Show View > Servers에 있음) create a new server 클릭
2) 내가 쓸 서버 선택해주기 (나는 Apache > Tomcat v9.0 Server)
3) 앞에서 다운로드한 tomcat 경로 넣어주기
4) Servers에 tomcat 잘 들어가 있는거 확인 완료.
나의 회원가입 프로젝트를 진행하기 위해 Dynamic Web Project를 생성해준다. (이름만 설정해서 생성해주기)
처음 생성하면 오른쪽과 같은 구조로 프로젝트가 생성된다.
Dynamic Web Project 구조 1. Java Resources : Web Application 실행에 필요한 java 관련 resource를 포함한다. 2. WebContent : Web Application 실행에 필요한 html, javascript, css, JSP, image 등 웹 컨텐츠를 포함한다. Web Application 설정 파일인 web.xml도 WebContent/WEB-INF/ 에 위치한다.
여기에 앞에서 다운로드한 JSTL, MySQL connector도 추가해주자.
WebContent > WEB-INF > lib에 드래그하고 copy file 해서 추가하면 된다.
Step2. JSP(Java Server Page) 만들기
필요한 것들은 세팅해주었으니, 이제 jsp를 만들어보자.
💡jsp란? html 안에 java 코드를 넣어서 동적으로 웹페이지를 생성하여 브라우저에게 돌려준다.
클라이언트의 요청에 의해 jsp가 실행되면 ➡️ servlet(자바를 이용하여 웹에서 실행되는 프로그램을 작성하는 기술, java 안에 html을 포함한다.)으로 바뀐 후 ➡️ 컴파일러에 의해 class 파일로 컴파일 되고 ➡️ execute되어 ➡️ 그 결과인 html 페이지가 다시 클라이언트에게 전달된다.
※ jsp는 서버에서 완전한 html을 만들어서 주고 페이지 단위로 처리되어야 하지만, 이제는 react와 같이 뭔가 변경된 사항이 있을 때 변경된 data만 가져오는 방식으로 처리한다고 한다. 즉, jsp는 더이상 사용하지 않는 방식이라는 얘기지만.. 아직 학습하는 단계이고, 사실 jsp밖에 안다뤄봤기 때문에 이번 프로젝트에서도 jsp를 사용할 예정.
jsp는 html 안에 java 코드가 있는, 즉 "html"이 메인이기 때문에 Java Resources가 아닌 WebContent 아래에 파일을 만들어주자.
✅ 실행하기
Ctrl+F11 > Run On Server > 바로 Finish 눌러도 되고, 이전에 다른 프로젝트들도 실행했었다면 remove 해준 다음에 Finish 해도 됨.
별도의 설정을 해주지 않았다면 아래의 왼쪽 화면처럼 이클립스 내에서 웹페이지가 보여질텐데, chrome 창에 띄워서 보고 싶으면 Window > Web Brower > 3 Chrome을 선택해주면 된다.
회원가입 할 때 다른 여러 정보들을 더 저장할 수도 있겠지만, 우선은 아이디와 비밀번호만 가지고 진행해 보겠다.
회원가입과 로그인을 위한 jsp
Step3. DB 만들기
사용자들의 정보를 저장하기 위한 데이터베이스를 MySQL을 이용해서 만들었다.
마찬가지로 우선 아이디와 비밀번호만 저장할 수 있도록 했다.
기본키로는 사용자마다 auto_increment로 번호를 부여하여 식별할 수 있도록 했다.
Step4. JAVA와 DBMS 연결하기 (DBUtill, DTO, DAO)
위에서 얘기했듯이 java와 DBMS를 연결하기 위해서는 JDBC가 필요하다.
JDBC를 이용해서 DB를 연결하려면
1. JDBC 드라이버 로드 2. DB 연결 3. SQL문 실행 4. DB 연결 끊기
이런 과정들이 필요하다.
이 중에서 1, 2, 4번은 매번 반복적으로 수행해야 하는 작업이기 때문에 이를 위한 클래스(DBUtil)를 따로 작성해줬다. (자세한 내용은 주석 참고)
3번의 'SQL문 실행' 과정은 DAO의 각각의 메서드에서 필요한 기능에 따라 다른 sql문을 실행해야 하기 때문에 DBUtil 클래스에는 포함되지 않는다.
💡DTO : Data Transfer Object. 데이터 교환을 위해 사용하는 객체 (브라우저 form ↔️ 서버)
💡DAO : Data Access Object. DB의 데이터에 접근하기 위한 객체 (서버 ↔️ DB)
DTO에 해당하는 User 클래스에는
사용자의 정보인 id, password를 담을 수 있는 변수를 만들고, 생성자와 getter, setter를 만들었다.
DAO에 해당하는 UserDaoImpl 클래스에서는 (UserDao 인터페이스를 만들고, 이를 implement한 클래스이다)
DB에 접근해야 하기 때문에 앞에서 만든 DBUtil을 가져와서 사용한다.
1) 회원가입
입력받은 아이디와 비밀번호를 DB에 저장하는 기능을 구현한다. PreparedStatement 방법을 사용해서 sql문을 미리 담아두고 값을 할당한 후에 execute 해준다.
2) 로그인
입력받은 아이디와 비밀번호로 DB를 조회하여 일치하는 결과를 반환하는 기능을 구현한다. 마찬가지로 preparedStatement 방법을 사용했고, 회원가입 기능과 달리 query 실행 결과 얻어지는 반환 값이 있기 때문에 resultset을 이용해 반환 값을 가져왔다.
DBUtil.java 코드 보기
package util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/**
* Mysql DB 1) 연결 객체를 제공해주고, 2) 사용했던 자원을 해제하는 기능을 제공하는 클래스
*
*/
public class DBUtil {
// DB와 연결하기위해 필요한 DB의 URL
// url은 jdbc:mysql://[host][:port]/[database][?propertyName1][=propertyValue1]형태로 작성한다.
// serverTimezone=UTC 설정이 없으면 오류가 발생하므로 주의한다.
private final String url = "jdbc:mysql://localhost:3306/login?serverTimezone=UTC";
// DB의 USER 이름
private final String username = "yudaeng";
// 위 USER의 PASSWORD
private final String password = "yudaeng";
// Mysql 드라이버 클래스 이름
private final String drivername = "com.mysql.cj.jdbc.Driver";
// 싱글턴 패턴 (객체가 한 번만 생성되도록 한다 => 객체의 유일성 보장)
private static DBUtil instance = new DBUtil();
private DBUtil() {
try {
// JDBC 드라이버를 로드한다! 즉, JVM 메모리에 JDBC 관련한 것들을 미리 올려 놓는다.
Class.forName(drivername);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static DBUtil getInstance() {
return instance;
}
/**
* DB와의 connection 생성 및 반환
* @return
* @throws SQLException
*/
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(url, username, password);
}
/**
* 사용한 리소스들을 정리. 자원 해제.
* Connection, Statement, ResultSet 모두 AutoCloseable 타입이다.
* ... 을 이용하므로 필요에 따라서
* select 계열 호출 후는 ResultSet, Statement, Connection
* dml 호출 후는 Statement, Connection 등 다양한 조합으로 사용할 수 있다.
* @param closeables
*/
public void close(AutoCloseable... closeables) {
for (AutoCloseable c : closeables) {
if (c != null) {
try {
c.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
User.java 코드 보기
// dto : data trasfer object
// 데이터 교환을 위해 사용하는 객체
// 브라우저에서 form에 입력받은 데이터를 dto에 넣어서 전달한다.
package dto;
public class User {
private String id; // 아이디
private String password; // 비밀번호
// 기본 생성자
public User() {
}
public User(String id, String password) {
this.id = id;
this.password = password;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "User [id=" + id + ", password=" + password + "]";
}
}
UserDao.java 코드 보기
// dao : data access object
// DB의 데이터에 접근하기 위한 객체
// dto를 통해 데이터를 받은 서버가 dao를 이용해 DB에 넣는다.
package dao;
import dto.User;
public interface UserDao {
// 회원가입
public boolean registUser(User user);
// 로그인
public User loginUser(String id, String password);
}
UserDaoImpl.java 코드 보기
package dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import dto.User;
import util.DBUtil;
public class UserDaoImpl implements UserDao {
// DB와의 연결을 위한 객체 가져오기
private final DBUtil util = DBUtil.getInstance();
// 싱글턴 패턴
private static UserDaoImpl instance = new UserDaoImpl();
private UserDaoImpl() {
};
public static UserDaoImpl getInstance() {
return instance;
}
/**
* 회원가입을 위해 받은 id, password 등의 데이터를 DB에 저장하기
* 실행 결과 잘 됐으면 true, 잘 안됐으면 false 반환
*/
@Override
public boolean registUser(User user) {
// 사용할 sql문 (users 테이블에 id, password 데이터 삽입하고 싶음)
String sql = "INSERT INTO `users` (id, password) VALUES (?,?);";
// DB 연결 객체
Connection conn = null;
// sql문 실행 객체
PreparedStatement pstmt = null;
boolean result = false;
try {
// DB 연결 객체 얻기
conn = util.getConnection();
// 위에서 사용할 sql문을 통해 pstmt 구문 객체 생성
pstmt = conn.prepareStatement(sql);
// dto에서 데이터 가져와서 DB에 넣어줘야 함
pstmt.setString(1, user.getId());
pstmt.setString(2, user.getPassword());
// sql 실행 결과 영향받은 행 수가 0보다 큰 경우 정상 실행
result = pstmt.executeUpdate() > 0 ? true : false;
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 자원 해제해주기
util.close(conn, pstmt);
}
return result;
}
/**
* 로그인 정보(id, password)를
*/
@Override
public User loginUser(String id, String password) {
String sql = "SELECT * FROM `users` WHERE id = ? AND password = ?;";
Connection conn = null;
PreparedStatement pstmt = null;
// sql문 실행 결과 집합
ResultSet rs = null;
// 로그인 정보를 이용해서 DB 조회한 결과를 반환할 User 객체
User user = new User();
try {
conn = util.getConnection();
pstmt = conn.prepareStatement(sql);
// 로그인 하려는 아이디, 비밀번호 정보 담아서
pstmt.setString(1, id);
pstmt.setString(2, password);
// 실행. sql문 실행 결과가 rs에 저장됨
rs = pstmt.executeQuery();
if (rs.next()) { // 다음 결과가 있으면
user.setId(rs.getString("id")); // 컬럼의 값을 가져와서 user 객체에 저장하기
user.setPassword(rs.getString("password"));
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 자원 해제
util.close(conn, pstmt, rs);
}
return user;
}
}
Step5. MainServlet 만들기 (암호화 과정 없이!)
우선 암호화 과정 없이, 단순히 아이디와 비밀번호를 DB에 저장하고 가져오는 과정은 아래와 같다.
( ※ 아래 캡쳐에서 userNo가 4로 저장되어 있는데, 이것은 userNo를 auto_increment로 설정했기 때문이다. auto_increment는 테이블 상에서 데이터가 사라져도 진행 중이던 숫자를 기준으로 1씩 증가시켜 나가는데, 캡쳐 하기 전에 3개의 데이터를 넣었다가 지웠기 때문에 아래 보이는 yudaeng, 12345 데이터가 첫번째 데이터처럼 보이지만, auto_increment가 보기에는 사실 4번째 데이터인 것이다. )
java.security.MessageDigest 클래스를 이용해서 SHA-256 알고리즘을 수행하는 객체를 생성해서 사용하면 된다.
SHA-256 알고리즘의 자세한 중간 과정들을 몰라도 가져다 쓸 수 있는 것이다!
코드의 대략적인 진행과정을 보자면
1) encrypt 메서드에서 text 변수로 비밀번호를 받아와서 인코딩(string ➡️ byte[]) 과정을 거친다.
2) 인코딩된 값으로 객체(md) 내에 저장된 digest 값을 갱신시킨다.
✅ 메시지 다이제스트(Message Digest)란 암호화의 결과로 출력되는 고정된 길이의 값을 말한다!
3) 해시화된 값을 다시 디코딩(byte[] ➡️ string)해서 최종 변환된 값을 반환한다.
이외에 자세한 내용들은 주석으로 남겨 놓았다.
암호화 결과를 확인해보고 싶어서 SHA256 클래스 내에 main을 만들고 encrypt 결과를 sysout 해보았다.
'유댕둥당'의 암호화 결과 밑에 console창에 보이는 것처럼 뭔가 잘 암호화된 것 같은 결과가 출력되었다!
확인 완료했으니 다시 main 지워주고..
SHA256.java 코드 보기
package encrypt;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
// SHA-256 암호화하기
public class SHA256 {
public String encrypt(String text) throws NoSuchAlgorithmException {
// MessageDigest 클래스는 자바에서 단방향 해시 함수 값을 구할 때 사용한다.
// SHA-256 알고리즘을 수행하는 MessageDigest 객체 생성
MessageDigest md = MessageDigest.getInstance("SHA-256");
// getBytes() : String을 바이트코드로 "인코딩" 해준다. 디폴트는 사용자 플랫폼의 기본 charset.
// update(byte[] input) : 객체 내에 저장된 digest 값을 갱신시킨다.
md.update(text.getBytes());
// digest() : update() 실행 및 계산 완료 후 해시화된 값을 반환한다.
return bytesToHex(md.digest());
}
// byte 배열을 hex string으로 변환하기 (디코딩)
private String bytesToHex(byte[] bytes) {
StringBuilder builder = new StringBuilder();
for (byte b : bytes) {
// &x : 16진수 / 2 : 두자리수로 만들겠다 / 0 : 빈 공간을 공백이 아닌 0으로 채워라
builder.append(String.format("%02x", b));
}
return builder.toString();
}
}
MainServlet에서 password를 받아오던 부분들에 암호화를 적용해준다.
회원가입을 할 때도 당연히 비밀번호를 암호화한 후 DB에 저장해야 하고,
로그인을 할 때도 DB에 저장된 비밀번호가 암호화되어 있기 때문에, 똑같이 암호화된 비밀번호를 가져가야 제대로 비교할 수 있다.
암호화 적용된 MainServlet.java 코드 보기
package servlet;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import dao.UserDaoImpl;
import dto.User;
import encrypt.SHA256;
//이 서블릿이 호출되기 위해서는 url 상에 http://server_ip:port/context_name/main 이 필요하다.
@WebServlet("/main")
public class MainServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private UserDaoImpl dao = UserDaoImpl.getInstance();
private SHA256 sha = new SHA256(); // 이전에 작성한 SHA256 클래스 임포트 해오기
/**
* get 방식의 요청에 대해 응답하는 메서드이다. front controller pattern을 적용하기 위해 내부적으로 process를
* 호출한다.
*/
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
process(req, resp);
}
/**
* post 방식의 요청에 대해 응답하는 메서드이다. front controller pattern을 적용하기 위해 내부적으로 process를
* 호출한다.
*/
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// post 요청 시 한글 파라미터의 처리를 위해 encoding을 처리한다.
req.setCharacterEncoding("utf-8");
process(req, resp);
}
private void process(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String act = req.getParameter("act");
switch (act) {
case "regist":
doRegist(req, resp);
break;
case "login":
doLogIn(req, resp);
break;
case "logout":
doLogOut(req, resp);
break;
}
}
private void doRegist(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// req 객체에서 전달된 parameter를 추출한다.
// login.jsp의 input 태그에서 입력한 id, password
String id = req.getParameter("id");
String password = req.getParameter("password");
// 전달받은 파라미터로 User 객체를 생성하고, attribute에 등록한다.
User user = null;
try {
String encPassword = sha.doEncrypt(password);
user = new User(id, encPassword); // 비밀번호 암호화하기!!
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
req.setAttribute("user", user);
String msg = "회원가입 실패 (아이디 중복)";
// 생성한 user를 DB에 등록하기
if (dao.registUser(user)) {
msg = "회원가입 성공!";
}
req.setAttribute("msg", msg);
// JSP 화면 호출을 위해 RequestDispatcher의 forward를 사용한다.
// 이때 연결할 jsp의 이름을 넘겨준다. forward에서 /는 context root를 나타낸다.
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
private void doLogIn(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String id = req.getParameter("id");
String password = req.getParameter("password");
User user = null;
try {
// DB에 암호화된 비밀번호가 저장되어 있기 때문에
// 비교를 위해서는 로그인 할 때도 암호화된 비밀번호를 가져가야 한다.
user = dao.loginUser(id, sha.doEncrypt(password));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
String msg = "로그인 성공!";
HttpSession session = req.getSession();
// DB에 저장된 정보가 없다면 null 반환
if (user.getId() == null) {
msg = "로그인 실패!";
} else { // DB에 저장된 정보가 있다면
// loginUser 정보는 request가 아닌 session에 저장하기 (암호화 작업 후에 암호화된 비밀번호를 페이지에서 확인할 예정)
session.setAttribute("loginUser", user);
}
req.setAttribute("msg", msg);
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
private void doLogOut(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
HttpSession session = req.getSession();
session.invalidate(); // session에 저장된 loginUser 정보를 날려준다.
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
}
💡 암호화 적용 후에 어떤 아이디를 넣어도 회원가입에 실패하는 현상이 발생했는데, DB의 user 테이블에서 password의 타입을 VARCHAR(40)으로 설정했어서 암호화된 비밀번호가 들어가지 못했던 것이었다.
암호화된 비밀번호의 길이를 고려하여 VARCHAR(100)으로 변경해줬더니 잘 동작했다. 휴우~~