
회원가입, 로그인 구현하기
기본 구조 만들기
webapp - WEB-INF - web.xml (기본 코드 끌어오기)
tomcat 설정
의존성 추가
servlet-api -> jakarta ..
servlet.jsp-api ->jakarta servlet Jsp
jstl-api
jstl-impl
lombok
ojdbc11
mybatis
slf4j api
구현체 logback-classic
mockito
mockito jupiter
javafaker
dependencies {
compileOnly 'jakarta.servlet:jakarta.servlet-api:6.0.0'
testCompileOnly 'jakarta.servlet:jakarta.servlet-api:6.0.0'
compileOnly 'jakarta.servlet.jsp:jakarta.servlet.jsp-api:3.1.1'
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:3.0.0'
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl:3.0.1'
compileOnly 'org.projectlombok:lombok:1.18.32'
testCompileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
implementation 'com.oracle.database.jdbc:ojdbc11:23.4.0.24.05'
implementation 'org.mybatis:mybatis:3.5.16'
implementation 'org.slf4j:slf4j-api:2.0.13'
testImplementation 'ch.qos.logback:logback-classic:1.5.6'
testImplementation 'org.mockito:mockito-core:5.12.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.12.0'
testImplementation 'com.github.javafaker:javafaker:1.0.2'
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
}

자바쪽 경로와 resource경로를 동일하게 만드는게 좋다!
mybatis공식사이트

sqlplus system/oracle


CREATE TABLE MEMBER(
USER_NO NUMBER(10) PRIMARY KEY,
EMAIL VARCHAR2(60) NOT NULL UNIQUE,
PASSWORD VARCHAR2(65) NOT NULL,
USER_NAME VARCHAR2(30) NOT NULL,
USER_TYPE VARCHAR2(10) DEFAULT 'USER'
CHECK(USER_TYPE IN ('USER','ADMIN')),
REG_DT DATE DEFAULT SYSDATE,
MOD_DT DATE
);
CREATE SEQUENCE SEQ_MEMBER;
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties>
<property name="driver" value="oracle.jdbc.driver.OracleDriver"/>
<property name="url" value="jdbc:oracle:thin:@localhost:1521:XE"/>
<property name="username" value="PROJECT3"/>
<property name="password" value="oracle"/>
</properties>
<environments default="dev">
<environment id="dev">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="org/mybatis/example/BlogMapper.xml"/>
</mappers>
</configuration>

회원쪽 도메인추가

MemberMapper.xml - 마이바티스 사이트 참고
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.choongang.member.mapper.MemberMapper">
</mapper>
MemberMapper 인터페이스 정의

mybatis쪽 하단에 mapper경로 추가

DBConn 클래스

package org.choongang.global.configs;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.Reader;
public class DBConn {
private static SqlSessionFactory factory;
static {
try {
Reader reader = Resources.getResourceAsReader("org/choongang/global/configs/mybatis-config.xml");
factory = new SqlSessionFactoryBuilder().build(reader);
} catch (IOException e) {
e.printStackTrace();
}
}
public static SqlSession getSesson(boolean autoCommit){
return factory.openSession(autoCommit);
}
public static SqlSession getSession(){
return getSesson(true);
}
}

경로 똑같이
package org.choongang.configs;
import org.apache.ibatis.session.SqlSession;
import org.choongang.global.configs.DBConn;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class DBConnTest {
@Test
@DisplayName("DB연결 테스트")
void dbConnectionTest(){
assertDoesNotThrow(()->{
SqlSession session = DBConn.getSession();
System.out.println(session);
});
}
}
테스트 통과~🌼
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration>
<configuration>
<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %5p %t - %m%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="stdout"/>
</root>
<logger name="org.choongang.member.mapper" level="DEBUG"/>
</configuration>
p 레벨
m 메시지
n 줄개행
d 날짜와 시간
info - info를 포함한 모든 레벨
mapper쪽 자세하게 나오게 경로 추가


서블릿 설정
JoinController
✔LoginController도 동일하게 처리

회원가입 양식, 로그인 양식
기억할것....
RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/templates/member/join.jsp"); //버퍼추가
rd.forward(req, resp); //버퍼치환

서버 켜고 뷰 이동 확인해보기 ~~
localhost:3000/day07/member/join
localhost:3000/day07/member/login
경로이동되면 뷰 연동 잘 된거다.

강사님 깃허브 경로 가서 tag 코드 복사해오자


SITE_TITLE=데굴데굴학원
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="layout" tagdir="/WEB-INF/tags/layouts" %> <%--이 경로 태그파일들 다 불러옴--%>
<layout:main title="회원가입">
<h1> 회원가입</h1>
</layout:main>
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="layout" tagdir="/WEB-INF/tags/layouts" %> <%--이 경로 태그파일들 다 불러옴--%>
<layout:main title="로그인">
<h1>로그인</h1>
</layout:main>
연동확인

양식구현
회원가입 form
<%@ page contentType="text/html; charset=UTF-8" %>
<%@taglib prefix="c" uri="jakarta.tags.core"%>
<%@ taglib prefix="layout" tagdir="/WEB-INF/tags/layouts" %> <%--이 경로 태그파일들 다 불러옴--%>
<c:url var="actionUrl" value="/member/join"/>
<layout:main title="회원가입">
<h1> 회원가입</h1>
<form method="post" action="${actionUrl}" autocomplete="off">
<dl>
<dt>이메일</dt>
<dd>
<input type="text" name="email">
</dd>
</dl>
<dl>
<dt>비밀번호</dt>
<dd>
<input type="password" name="password">
</dd>
</dl>
<dl>
<dt>비밀번호 확인</dt>
<dd>
<input type="password" name="confirmPassword">
</dd>
</dl>
<dl>
<dt>회원명</dt>
<dd>
<input type="text" name="userName">
</dd>
</dl>
<div>
<input type="checkbox" name="termsAgree" value="ture" id="termsAgree">
<label for="termsAgree">회원가입 약관에 동의합니다.</label>
</div>
<button type="submit">가입하기</button>
</form>
</layout:main>

로그인 form
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="layout" tagdir="/WEB-INF/tags/layouts" %> <%--이 경로 태그파일들 다 불러옴--%>
<%@taglib prefix="c" uri="jakarta.tags.core" %>
<c:url var="actionUrl" value="/member/login"/>
<layout:main title="로그인">
<h1>로그인</h1>
<form method="POST" action="${actionUrl}" autocomplete="off">
<dl>
<dt>이메일</dt>
<dd>
<input type="text" name="email">
</dd>
</dl>
<dl>
<dt>비밀번호</dt>
<dd>
<input type="password" name="password">
</dd>
</dl>
<div>
<input type="checkbox" name="saveEmail" value="true" id="saveEmail">
<label for="saveEmail">이메일 저장하기</label>
</div>
<button type="submit">로그인</button>
</form>
</layout:main>
서비스 - 모델
DAO는 "Data Access Object"의 약자입니다. 이는 데이터베이스와 상호작용하는 객체를 말합니다. DAO는 데이터베이스와의 직접적인 통신을 처리하며, 데이터베이스에서 데이터를 읽고 쓰는 작업을 수행합니다. DTO는 "Data Transfer Object"의 약자이며, 계층 간에 데이터를 전달하기 위한 객체입니다. DTO는 주로 서비스 계층과 데이터베이스 계층 간의 데이터를 주고받는 데 사용됩니다.
👩🏫test쪽에서 작업 먼저하기

JoinService
package org.choongang.member.services;
import org.choongang.member.controllers.RequestJoin;
//회원가입 기능
public class JoinService {
public void process(RequestJoin form){
}
}
기능은 넣지않은 상태
JoinServiceTest

초기 확인 통과 ✅



CommonException 클래스
package org.choongang.global.exceptions;
import jakarta.servlet.http.HttpServletResponse;
public class CommonException extends RuntimeException { //유연한 예외 처리를 이해 RuntimeException 상속
private int status; //응답 코드
public CommonException(String message){
this(message, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 500 코드 에러 상수형태로
}
public CommonException(String message, int status) {
super(message);
this.status = status;
}
public int getStatus() {
return status;
}
}
BadRequestException 클래스
package org.choongang.global.exceptions;
import jakarta.servlet.http.HttpServletResponse;
public class BadRequestException extends CommonException{
//기본 문구
public BadRequestException() {
this("잘못된 요청입니다.");
}
public BadRequestException(String mesage) {
//응답코드 400으로 고정
super(mesage, HttpServletResponse.SC_BAD_REQUEST);
}
}


package org.choongang.member.services;
import org.choongang.global.exceptions.BadRequestException;
import org.choongang.member.controllers.RequestJoin;
//회원가입 기능
public class JoinService {
public void process(RequestJoin form){
String email = form.getEmail();
if(email == null || email.isBlank()){
throw new BadRequestException("이메일을 입력하세요");
}
}
}
이후 JoinService테스트 실행하면 통과된다. - void requiredFieldTest()
🤹♂️
속보속보
Validator로 공통 예외틀로 처리하자
💥💥💥💥💥
테스트 쪽에 인터페이스 추가

package org.choongang.global.validators;
public interface Validator<T> {
void check(T form);
}

JoinValidator에 Validator 구현체 생성!
상속은 확장보다는 구성이다..
◾ JoinService 수정
확장엔 닫혀잇고 --엔 열려있는
개방폐쇄원칙..


◾ JoinServiceTest 수정


MemberServiceProvider
package org.choongang.member.services;
import org.choongang.member.validators.JoinValidator;
//객체 조립기
public class MemberServiceProvider {
private static MemberServiceProvider instance;
private MemberServiceProvider(){}
public static MemberServiceProvider getInstance(){
if(instance == null){
instance = new MemberServiceProvider();
}
return instance;
} //싱글톤
//회원가입 검증
public JoinValidator joinValidator(){
return new JoinValidator();
}
public JoinService joinService(){
return new JoinService(joinValidator());
}
}

필수항목 검증을 더 편하게 하기 위한..

기능만 공유하는 인터페이스
package org.choongang.global.validators;
public interface RequiredValidator {
default void checkRequired(String str, RuntimeException e){
if(str == null || str.isBlank()){
throw e;
}
}
}
package org.choongang.member.validators;
import org.choongang.global.exceptions.BadRequestException;
import org.choongang.global.validators.RequiredValidator;
import org.choongang.global.validators.Validator;
import org.choongang.member.controllers.RequestJoin;
public class JoinValidator implements Validator<RequestJoin>, RequiredValidator {
@Override
public void check(RequestJoin form) {
/**
* 필수항목 검증 - 이메일, 비밀번호, 비밀번호확인, 회원명, 약관 동의
*/
String email = form.getEmail();
String password = form.getPassword();
String confirmPassword = form.getConfirmPassword();
String userName = form.getUserName();
boolean termsAgree = form.isTermsAgree();
checkRequired(email, new BadRequestException("이메일을 입력하세요."));
checkRequired(password,new BadRequestException("비밀번호를 입력하세요."));
checkRequired(confirmPassword, new BadRequestException("비밀번호를 확인하세요."));
checkRequired(userName,new BadRequestException("회원명을 입력하세요."));
checkTrue(termsAgree, new BadRequestException("약관에 동의하세요"));
}
}
RequiredValidator 인터페이스 코드 추가

JoinServiceTest 테스트 메서드 추가


◾ JoinValidator
check안에 코드 추가

◾ JoinServiceTest


package org.choongang.global.validators;
public interface EmailValidator {
default boolean checkEmail(String email){
// 계정@도메인.com|co.kr
String pattern = "[^@]+@[^.]+\\.[a-z]+";
return email.matches(pattern);
}
}
◾ JoinValidator
check안에 코드 추가


◾ JoinValidator
check안에 코드 추가

회원 도메인쪽에만 해당하는 예외

DuplicatedMemberException
BadRequestException상속
package org.choongang.member.exceptions;
import org.choongang.global.exceptions.BadRequestException;
public class DuplicatedMemberException extends BadRequestException {
public DuplicatedMemberException(){
super("이미 가입된 회원입니다.");
}
}
DB데이터 담을 수 있는 데이터 클래스
entities

package org.choongang.member.entities;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Member {
private long userNo;
private String email;
private String password;
private String userName;
private LocalDateTime regDt;
private LocalDateTime modDt;
}
DB항목 찾아서 넣어줄거임
DB와 자바의 변수명이 다르기때문에... resultMap에서 매핑시켜준다
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.choongang.member.mapper.MemberMapper">
<resultMap id="memberMap" type="org.choongang.member.entities.Member">
<result column="USER_NO" property="userNo"/>
<result column="EMAIL" property="email"/>
<result column="PASSWORD" property="password"/>
<result column="USER_NAME" property="userName"/>
<result column="REG_DT" property="regDt"/>
<result column="MOD_DT" property="modDt"/>
</resultMap>
</mapper>
...
<mapper namespace="org.choongang.member.mapper.MemberMapper">
<resultMap id="memberMap" type="org.choongang.member.entities.Member">
<result column="USER_NO" property="userNo"/>
<result column="EMAIL" property="email"/>
<result column="PASSWORD" property="password"/>
<result column="USER_NAME" property="userName"/>
<result column="REG_DT" property="regDt"/>
<result column="MOD_DT" property="modDt"/>
</resultMap>
<select id="exist" resultType="long"> <!-- 카운트 개수를 통해서 있는지 없는지 체크 -->
SELECT COUNT(*) FROM MEMBER WHERE EMAIL=#{email}
</select>
<select id="get" resultMap="memberMap">
SELECT * FROM MEMBER WHERE EMAIL=#{email}
</select>
<insert id="register">
INSERT INTO MEMBER (USER_NO, EMAIL, PASSWORD, USER_NAME)
VALUES (SEQ_MEMBER.NEXTVAL, #{email}, #{password}, #{userName})
</insert>
</mapper>
package org.choongang.member.mapper;
import org.choongang.member.entities.Member;
public interface MemberMapper {
long exist(String email);
Member get(String email);
int register(Member member);
}

package org.choongang.member.tests;
import com.github.javafaker.Faker;
import org.apache.ibatis.session.SqlSession;
import org.choongang.global.configs.DBConn;
import org.choongang.member.entities.Member;
import org.choongang.member.mapper.MemberMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.*;
public class MemberMapperTest {
private SqlSession session;
private MemberMapper mapper;
@BeforeEach
void init(){
session = DBConn.getSesson(false);
mapper = session.getMapper(MemberMapper.class);
}
@Test
@DisplayName("회원 등록, 조회 테스트")
void registerTest(){
Faker faker = new Faker(Locale.ENGLISH); //이메일 페이커써서 중복되지 않게
Member member = new Member();
//값 설정
member.setEmail(System.currentTimeMillis()+faker.internet()
.emailAddress());
member.setPassword(faker.regexify("\\w{8,16}").toLowerCase());
member.setUserName(faker.name().fullName());
int result = mapper.register(member); //등록
assertEquals(1,result);
long cnt = mapper.exist(member.getEmail()); //등록 회원 있는지 테스트
assertEquals(1L,cnt);
Member member2 = mapper.get(member.getEmail()); //이메일로 조회되는 회원이 있는지 테스트
assertEquals(member.getEmail(),member2.getEmail());
//기대값은 member.getEmail(), 실제 값은 member2.getEmail()
}
@AfterEach
void destroy(){
session.rollback();
}
}
//회원가입 기능
public class JoinService {
private Validator<RequestJoin> validator;
private MemberMapper mapper;
public JoinService(Validator<RequestJoin> validator, MemberMapper mapper) {
this.validator = validator;
this.mapper = mapper;
}
public void process(RequestJoin form){
//유효성 검사
validator.check(form); //예외발생안하면 검증 성공
}
}
JoinValidator
구성 추가

MemberServiceProvider
코드 추가 및 수정

//객체 조립기
public class MemberServiceProvider {
private static MemberServiceProvider instance;
private MemberServiceProvider(){}
public static MemberServiceProvider getInstance(){
if(instance == null){
instance = new MemberServiceProvider();
}
return instance;
} //싱글톤
public MemberMapper memberMapper(){
SqlSession session = DBConn.getSession();
return session.getMapper(MemberMapper.class);
}
//회원가입 검증
public JoinValidator joinValidator(){
return new JoinValidator(memberMapper());
}
public JoinService joinService(){
return new JoinService(joinValidator(), memberMapper());
}
}
◾ JoinValidator
check안에 코드 추가


비밀번호 처리는?
참고
암호화
- 양방향 암호화: 암호화 - 복호화
AES256, ARIA- 단방향 암호화: 해시(HASH) - 복호화 불가
: 고정해시 - 같은 값에 대해서 같은 해시값 -md5,sha1, sha256, sha512...
: 유동해시

jbcrypt 의존성추가

비밀번호 해시화 JoinService
//회원가입 기능
public class JoinService {
private Validator<RequestJoin> validator;
private MemberMapper mapper;
public JoinService(Validator<RequestJoin> validator, MemberMapper mapper) {
this.validator = validator;
this.mapper = mapper;
}
public void process(RequestJoin form){
//유효성 검사
validator.check(form); //예외발생안하면 검증 성공
//비밀번호 해시화 - BCrypt
String hash = BCrypt.hashpw(form.getPassword(),BCrypt.gensalt(12));
Member member = new Member();
member.setEmail(form.getEmail());
member.setPassword(hash);
member.setUserName(form.getUserName());
int result = mapper.register(member);
if(result < 1){
throw new BadRequestException("회원가입에 실패하였습니다.");
}
}
}

수정2

수정3

전체 테스트코드
@ExtendWith(MockitoExtension.class) //모의객체 사용
@DisplayName("회원가입 기능 테스트")
public class JoinServiceTest {
private JoinService joinService;
private MemberMapper mapper;
@BeforeEach
void init(){
joinService = MemberServiceProvider.getInstance().joinService();
mapper = DBConn.getSession().getMapper(MemberMapper.class);
}
RequestJoin getData(){
Faker faker = new Faker(Locale.ENGLISH);
RequestJoin form = RequestJoin
.builder()
.email(System.currentTimeMillis() + faker.internet().emailAddress())
.password(faker.regexify("\\w{8}").toLowerCase())
.userName(faker.name().fullName())
.termsAgree(true)
.build();
form.setConfirmPassword(form.getPassword());
return form;
}
@Test
@DisplayName("회원가입 성공시 예외가 발생하지 않음")
void successTest() {
RequestJoin form = getData();
assertDoesNotThrow(() -> {
joinService.process(form);
});
//가입된 이메일로 회원이 조회 되는지 체크
Member member = mapper.get(form.getEmail());
assertEquals(form.getEmail(),member.getEmail());
}
@Test
@DisplayName("필수 입력항목(이메일, 비밀번호, 비밀번호확인, 회원명, 약관동의)검증, 검증 실패시 BadRequestException발생")
void requiredFieldTest(){
//문제가 있는 경우 상황에 따라서 응답코드 던져주기
//Bad request -> 400
// assertThrows(BadRequestException.class, ()->{ //예외 발생해야 통과됨
// RequestJoin form = getData();
// form.setEmail(null);
// joinService.process(form);
// });
assertAll(
() -> requiredEachFieldTest("email", true,"이메일"),
() -> requiredEachFieldTest("email",false,"이메일"),
() -> requiredEachFieldTest("password", true,"비밀번호"),
() -> requiredEachFieldTest("password",false,"비밀번호"),
() -> requiredEachFieldTest("confirmPassword",true,"비밀번호를 확인"),
() -> requiredEachFieldTest("confirmPassword",false,"비밀번호를 확인"),
() -> requiredEachFieldTest("userName",true,"회원명"),
() -> requiredEachFieldTest("userName",false,"회원명"),
() -> requiredEachFieldTest("termsAgree",false,"약관")
);
}
void requiredEachFieldTest(String field, boolean isNull, String keyword){
BadRequestException thrown = assertThrows(BadRequestException.class,()->{
RequestJoin form = getData();
if(field.equals("email")){
form.setEmail(isNull?null:" ");
}else if(field.equals("password")){
form.setPassword(isNull?null:" ");
}else if(field.equals("confirmPassword")){
form.setConfirmPassword(isNull?null:" ");
}else if(field.equals("userName")){
form.setUserName(isNull?null:" ");
}else if(field.equals("termsAgree")){
form.setTermsAgree(false);
}
joinService.process(form);
}, field+" 테스트");
String message = thrown.getMessage();
assertTrue(message.contains(keyword),field+" 키워드 테스트");
}
@Test
@DisplayName("비밀번호와 확인이 일치하지 않으면 BadRequestException 발생")
void passwordMismatchTest(){
BadRequestException thrown = assertThrows(BadRequestException.class,() ->{
RequestJoin form = getData();
form.setConfirmPassword(form.getPassword()+"**");
joinService.process(form);
});
String message = thrown.getMessage();
assertTrue(message.contains("비밀번호가 일치하지"));
}
@Test
@DisplayName("이메일이 형식에 맞지 않으면 BadRequestException 발생")
void emailPatternTest(){
BadRequestException thrown = assertThrows(BadRequestException.class,()->{
RequestJoin form = getData();
form.setEmail("*****");
joinService.process(form);
});
String message = thrown.getMessage();
assertTrue(message.contains("이메일 형식이"));
}
@Test
@DisplayName("비밀번호 자리수가 8자리 미만이면 BadRequestException 발생")
void passwordLengthTest(){
BadRequestException thrown = assertThrows(BadRequestException.class, ()->{
Faker faker = new Faker();
RequestJoin form = getData();
form.setPassword(faker.regexify("\\w{3,7}").toLowerCase());
form.setConfirmPassword(form.getPassword());
joinService.process(form);
});
String message = thrown.getMessage();
assertTrue(message.contains("8자리 이상"));
}
@Test
@DisplayName("이미 가입된 메일인 경우 DuplicatedMemberException 발생")
void duplicateEmailTest() {
MemberServiceProvider provider = MemberServiceProvider.getInstance();
assertThrows(DuplicatedMemberException.class, () -> {
RequestJoin form = getData();
provider.joinService().process(form);
provider.joinService().process(form);
});
}
}
- 필수항목체크(이메일, 비밀번호)
- 이메일로 회원이 등록되어 있는지 체크
- 비밀번호 검증
- 세션에 회원정보 유지
기본 TDD테스트

@ExtendWith(MockitoExtension.class)
@DisplayName("로그인 기능 테스트")
public class LoginServiceTest {
@Test
@DisplayName("로그인 성공시 예외가 발생하지 않음")
void successTest(){
//예외발생 없으면 통과
assertDoesNotThrow(()->{
LoginService loginService = new LoginService();
loginService.process(); //로그인 기능 처리
});
}
}

package org.choongang.member.services;
public class LoginService {
public void process(){ //로그인 기능 서비스
}
}

BeforeEach로 매번 객체 생성하게 만들어주었기 때문에 successTest안에 객체 생성 문 삭제
@ExtendWith(MockitoExtension.class)
@DisplayName("로그인 기능 테스트")
public class LoginServiceTest {
//매번 테스트할때마다 객체 생성할수 있게 넣어줌
private LoginService loginService;
@BeforeEach
void init(){
//객체 조립기에서 loginService객체 추가
loginService = MemberServiceProvider.getInstance().loginService();
}
@Test
@DisplayName("로그인 성공시 예외가 발생하지 않음")
void successTest(){
//예외발생 없으면 통과
assertDoesNotThrow(()->{
loginService.process(); //로그인 기능 처리
});
}
}
사용자가 보낸 데이터 -> getParameter
서버쪽에서 요청한 데이터 -> getAttribute
@ExtendWith(MockitoExtension.class)
@DisplayName("로그인 기능 테스트")
public class LoginServiceTest {
//모의객체
@Mock
private HttpServletRequest request;
private Faker faker; //자주 사용되까 객체 따로 만듦
//매번 테스트할때마다 객체 생성할수 있게 넣어줌
private LoginService loginService;
@BeforeEach
void init(){
//객체 조립기에서 loginService객체 추가
loginService = MemberServiceProvider.getInstance().loginService();
//성공데이터(가짜 데이터)
faker = new Faker(Locale.ENGLISH); //데이터는 영어로
setData(); //데이터 항상 초기화시켜주기 위해
}
void setData(){
setParam("email", faker.internet().emailAddress());
setParam("password", faker.regexify("\\w{8}.toLowerCase()"));
}
//가짜데이터 만드는 메서드
void setParam(String name, String value){
//가짜 데이터 스텁 생성
given(request.getParameter(name)).willReturn(value);
//request객체의 parameter에 name값이 주어지면 value값을 반환
}
@Test
@DisplayName("로그인 성공시 예외가 발생하지 않음")
void successTest(){
//예외발생 없으면 통과
assertDoesNotThrow(()->{
loginService.process(request); //로그인 기능 처리
});
}
@Test
@DisplayName("필수 입력 항목(이메일, 비밀번호) 검증, 검증 실패시 BadRequestException 발생")
void requiredFieldTest(){
//웹에서 입력한 데이터 받아서 서비스 처리
//사용자가 양식을 통해 입력한 데이터 서버로 넘어옴 -> HttpServletRequest 구현 객체로 넘어옴 getParameter(사용자가 보내줌)로 조회/ getAttribute(서버쪽에서 전달한 데이터)
}
}
LoginService

LoginServiceTest
필수항목 검증 코드 추가
@Test
@DisplayName("필수 입력 항목(이메일, 비밀번호) 검증, 검증 실패시 BadRequestException 발생")
void requiredFieldTest(){
//웹에서 입력한 데이터 받아서 서비스 처리
//사용자가 양식을 통해 입력한 데이터 서버로 넘어옴 -> HttpServletRequest 구현 객체로 넘어옴 getParameter(사용자가 보내줌)로 조회/ getAttribute(서버쪽에서 전달한 데이터)
//값이 null이거나 빈값일때 예외 발생
assertAll(
()-> requiredEachFieldTest("email",false,"이메일"),
()-> requiredEachFieldTest("email",true,"이메일"),
()-> requiredEachFieldTest("password",false,"비밀번호"),
()-> requiredEachFieldTest("password",true,"비밀번호")
);
}
//값이 null이거나 빈값인지 체크
//문구도 정확하게 나오는지 체크
void requiredEachFieldTest(String name, boolean isNull, String message){
//값 초기화
setData();
//발생할 예외는 BadRequestException이 되어야 한다.
BadRequestException thrown = assertThrows(BadRequestException.class, ()->{
if(name.equals("password")){ //비밀번호 검증
setParam("password",isNull?null:" ");
}else{ //이메일 검증
setParam("email",isNull?null:" ");
}
loginService.process(request);
}, name+" 테스트");
String msg = thrown.getMessage(); //던저진 예외 객체에서 예외 message 저장
assertTrue(msg.contains(message),name+" 키워드 테스트");
//true가 나오면 통과
//msg 객체에 message가 포함되어 있는지 확인 -> true여야 테스트 성공
//두번째 매개변수: 조건이 false일 경우 출력될 메시지
}

check 오버라이드!


의존역전원칙.....!!!
LoginService 에서 연동

LoginValidator 검증 추가
public class LoginValidator implements Validator<HttpServletRequest>, RequiredValidator { //검증하고자하는 전달 객체 지네릭 타입으로 선언
//RequiredValidator 에는 null이거나 빈값일때 예외처리 메서드, 참인지 체크하는 메서드 들어있음(참이 아니면 예외던짐) (checkedRequired, checkTrue)
@Override
public void check(HttpServletRequest form) {
String email = form.getParameter("email");
String password = form.getParameter("password");
//필수항목 검증
//null값 빈값 검증
checkRequired(email,new BadRequestException("이메일을 입력하세요."));
checkRequired(password,new BadRequestException("비밀번호를 입력하세요"));
}
}


form 코드
form = RequestJoin.builder()
.email(System.currentTimeMillis()+faker.internet().emailAddress())
.password(faker.regexify("\\w{8,16}").toLowerCase()) //8자리부터 16자리까지
.userName(faker.name().fullName())
.termsAgree(true)
.build();
form.setConfirmPassword(form.getPassword());
joinService.process(form); //이 데이터를 바탕으로 로그인 할 예정..

생성자 매개변수에 mapper대입
의존성 추가
package org.choongang.member.services;
import jakarta.servlet.http.HttpServletRequest;
import org.choongang.global.validators.Validator;
import org.choongang.member.mapper.MemberMapper;
public class LoginService {
//의존역전, 개방폐쇄 원칙 적용
private Validator<HttpServletRequest> validator;
private MemberMapper mapper;
public LoginService(Validator<HttpServletRequest> validator, MemberMapper mapper) {
this.validator = validator;
this.mapper = mapper;
}
public void process(HttpServletRequest request){ //로그인 기능 서비스
//로그인 유효성 검사
validator.check(request);
}
}


MemberServiceProvider 수정


session -> DB 쿼리에 쓰는 session임
가장 하단에 롤백추가
-> 테스트 할때 추가되고 테스트 끝나면 테이블에 넣은 데이터들 다 사라짐
-테스트할때만 쓰고 버리기
@AfterEach
void destroy(){
dbsession.rollback();
}


test파일 다 환경변수 mode=test로 추가해줌
테스트 많이 하다보면 데이터가 많을것임
비워주자~
TRUNCATE TABLE MEMBER;
2번 끝나면 조회한 데이터를 가지고 비번 검증
검증코드 추가
public class LoginValidator implements Validator<HttpServletRequest>, RequiredValidator { //검증하고자하는 전달 객체 지네릭 타입으로 선언
//RequiredValidator 에는 null이거나 빈값일때 예외처리 메서드, 참인지 체크하는 메서드 들어있음(참이 아니면 예외던짐) (checkedRequired, checkTrue)
private MemberMapper mapper;
public LoginValidator(MemberMapper mapper) {
this.mapper = mapper;
}
@Override
public void check(HttpServletRequest form) {
String email = form.getParameter("email");
String password = form.getParameter("password");
//필수항목 검증
//null값 빈값 검증
checkRequired(email,new BadRequestException("이메일을 입력하세요."));
checkRequired(password,new BadRequestException("비밀번호를 입력하세요"));
//이메일로 회원이 조회되는지 검증
String message ="이메일 또는 비밀번호가 일치하지 않습니다.";
Member member = mapper.get(email);
//회원이 조회 안되면 null값임
checkTrue(member != null, new BadRequestException(message));
/*
주의 할 점은 예측 불가능성을 줘야함
아이디가 틀렸습니다 하면 비번이 맞다는걸 알아채고
비밀번호가 틀렸습니다 하면 아이디는 맞다는걸 알아챌 수 있기 때문! 보안적인 측면
* */
}
}
MemberServiceProvider

LoginServiceTest
테스트 코드 추가

equals아님 contains임

public class LoginValidator implements Validator<HttpServletRequest>, RequiredValidator { //검증하고자하는 전달 객체 지네릭 타입으로 선언
//RequiredValidator 에는 null이거나 빈값일때 예외처리 메서드, 참인지 체크하는 메서드 들어있음(참이 아니면 예외던짐) (checkedRequired, checkTrue)
private MemberMapper mapper;
public LoginValidator(MemberMapper mapper) {
this.mapper = mapper;
}
@Override
public void check(HttpServletRequest form) {
String email = form.getParameter("email");
String password = form.getParameter("password");
//필수항목 검증
//null값 빈값 검증
checkRequired(email,new BadRequestException("이메일을 입력하세요."));
checkRequired(password,new BadRequestException("비밀번호를 입력하세요"));
//이메일로 회원이 조회되는지 검증
String message ="이메일 또는 비밀번호가 일치하지 않습니다.";
Member member = mapper.get(email);
//회원이 조회 안되면 null값임
checkTrue(member != null, new BadRequestException(message));
/*
주의 할 점은 예측 불가능성을 줘야함
아이디가 틀렸습니다 하면 비번이 맞다는걸 알아채고
비밀번호가 틀렸습니다 하면 아이디는 맞다는걸 알아챌 수 있기 때문! 보안적인 측면
* */
//비밀전호 일치 여부 체크
//실패시 비번 불일치,성공시 비번 일치
boolean isMatch = BCrypt.checkpw(password, member.getPassword());
//(사용자가 입력한데이터/ DB에 있는데이터) 일치 여부 true/false로 반환
checkTrue(isMatch, new BadRequestException(message));
}
}

브라우저별로 구분되는 개인데이터
브라우저마다 세션 id가 존재함
요청을 처리하는 과정중에 값이 담겨있기때문에 request로 세션 가져올수있음

init

수정

로그인 테스트 코드
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("로그인 기능 테스트")
public class LoginServiceTest {
//모의객체
@Mock
private HttpServletRequest request;
@Mock
private HttpSession session;
private Faker faker; //자주 사용되까 객체 따로 만듦
private RequestJoin form;
private SqlSession dbsession;
//매번 테스트할때마다 객체 생성할수 있게 넣어줌
private LoginService loginService;
@BeforeEach
void init(){
//객체 조립기에서 loginService객체 추가
loginService = MemberServiceProvider.getInstance().loginService();
JoinService joinService = MemberServiceProvider.getInstance().joinService();
//성공데이터(가짜 데이터)
faker = new Faker(Locale.ENGLISH); //데이터는 영어로
//환경변수에 따라 바뀜
dbsession = MemberServiceProvider.getInstance().getSession();
//회원 가입 -> 가입한 회원 정보로 email, password 스텁 생성
//중복 안시키기 위해 millis추가
form = RequestJoin.builder()
.email(System.currentTimeMillis()+faker.internet().emailAddress())
.password(faker.regexify("\\w{8,16}").toLowerCase()) //8자리부터 16자리까지
.userName(faker.name().fullName())
.termsAgree(true)
.build();
form.setConfirmPassword(form.getPassword());
joinService.process(form); //이 데이터를 바탕으로 로그인 할 예정..
setData(); //데이터 항상 초기화시켜주기 위해
//모의객체 세션넣어주기
given(request.getSession()).willReturn(session);
}
void setData(){
setParam("email", form.getEmail()); //실제 가입한 데이터로 체크
setParam("password", form.getPassword());
}
//가짜데이터 만드는 메서드
void setParam(String name, String value){
//가짜 데이터 스텁 생성
given(request.getParameter(name)).willReturn(value);
//request객체의 parameter에 name값이 주어지면 value값을 반환
}
@Test
@DisplayName("로그인 성공시 예외가 발생하지 않음")
void successTest(){
//예외발생 없으면 통과
assertDoesNotThrow(()->{
loginService.process(request); //로그인 기능 처리
});
//로그인 처리 완료시 HttpSession - setAttribute 메서드가 호출됨
//setAttribute가 호출 되었는지 체크
//세션쪽 값 호출 한번만.. -> only
then(session).should(only()).setAttribute(any(),any());
}
@Test
@DisplayName("필수 입력 항목(이메일, 비밀번호) 검증, 검증 실패시 BadRequestException 발생")
void requiredFieldTest(){
//웹에서 입력한 데이터 받아서 서비스 처리
//사용자가 양식을 통해 입력한 데이터 서버로 넘어옴 -> HttpServletRequest 구현 객체로 넘어옴 getParameter(사용자가 보내줌)로 조회/ getAttribute(서버쪽에서 전달한 데이터)
//값이 null이거나 빈값일때 예외 발생
assertAll(
()-> requiredEachFieldTest("email",false,"이메일"),
()-> requiredEachFieldTest("email",true,"이메일"),
()-> requiredEachFieldTest("password",false,"비밀번호"),
()-> requiredEachFieldTest("password",true,"비밀번호")
);
}
//값이 null이거나 빈값인지 체크
//문구도 정확하게 나오는지 체크
void requiredEachFieldTest(String name, boolean isNull, String message){
//값 초기화
setData();
//발생할 예외는 BadRequestException이 되어야 한다.
BadRequestException thrown = assertThrows(BadRequestException.class, ()->{
if(name.equals("password")){ //비밀번호 검증
setParam("password",isNull?null:" ");
}else{ //이메일 검증
setParam("email",isNull?null:" ");
}
loginService.process(request);
}, name+" 테스트");
String msg = thrown.getMessage(); //던저진 예외 객체에서 예외 message 저장
assertTrue(msg.contains(message),name+" 키워드 테스트");
//true가 나오면 통과
//msg 객체에 message가 포함되어 있는지 확인 -> true여야 테스트 성공
//두번째 매개변수: 조건이 false일 경우 출력될 메시지
}
@Test
@DisplayName("이메일로 회원이 조회 되는지 검증, 검증 실패시 BadRequestException 발생")
void memberExistTest() {
setParam("email", "****" + form.getEmail());
BadRequestException thrown = assertThrows(BadRequestException.class, () -> {
loginService.process(request);
//첫번째 매개변수의 예외와 같은 예외 발생시 테스트 통과
});
String message = thrown.getMessage();
assertTrue(message.contains("이메일 또는 비밀번호"));
//message객체에 아이디 또는 비밀번호 문구가 담겨있다면 true로 통과
}
@Test
@DisplayName("비밀번호 검증, 검증 실패시 BadRequestException")
void passwordCheckTest(){
setParam("password","****"+form.getPassword());
BadRequestException thrown = assertThrows(BadRequestException.class, ()->{
loginService.process(request);
});
String message = thrown.getMessage();
assertTrue(message.contains("이메일 또는 비밀번호"));
}
@AfterEach
void destroy(){
//dbsession.rollback();
}
}
test기능들 main java쪽으로 이동시켜주자


잘라내고 배포 가능한 쪽으로 옮겨줌!


컨트롤러쪽에 서비스 연동하기

기본 자료형 래퍼클래스
Integer
- parseInteger(문자열)
Long- parseLong(문자열)
Boolean- parseBoolean(문자열)
parse(자료형) -> 문자열을 기본자료형으로 바꿈
valueOf(문자열, 기본 자료형 값) -> 문자열을 래퍼클래스 객체로 변환
ex)
Integer.parseInt() -> int변환
Integer.valueOf() -> Integer 객체로 변환
//처리
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
try{
JoinService joinService = MemberServiceProvider.getInstance().joinService();
//RequestJoin(DTO)에서 setter을 통해 데이터를 다 넣어주는건 번거롭다.. 수정수정
joinService.process(req); //사용자 요청을 process에 넘겨주기!
//요청데이터 들어오면 DTO로 변환작업
}catch(CommonException e){
resp.setContentType("text/html;charset=UTF-8");
//스크립트태그형태로 화면 출력
PrintWriter out = resp.getWriter();
out.printf("<script>alert('%s');</script>", e.getMessage());
}
}
이메일 입력안하고 가입하기 눌렀을때 에러창 대신 alert뜨도록!

응답코드도 헤더에 싣어보내자


<응답코드>

500:
잘못입력했습니다 알림창 뜨고나서 아무 창이 안뜨는데 입력창이 다시 나오게끔 해야한다.

하지만 알림창 뜨고 페이지 왓다갓다 넘 불편하다
움직이지 않고 띄워주자!
back은 다시 없애주자
target -> 이동할 창에 대한 위치
◼예시

양식 제출할때 hiddenframe 추가하고 이쪽으로 제출될 수 있게 한다.



제출을 이쪽으로!

하나의 창에 머물러있게 했다

많이 쓰는 로직이니까..
함수만들기

컨트롤러에서

잘라내기!!!
resp.setContentType("text/html;charset=UTF-8");
resp.setStatus(e.getStatus()); //응답코드 400
//스크립트태그형태로 화면 출력
PrintWriter out = resp.getWriter();
out.printf("<script>alert('%s');</script>", e.getMessage());
package org.choongang.global;
import jakarta.servlet.http.HttpServletResponse;
import org.choongang.global.exceptions.CommonException;
import java.io.IOException;
import java.io.PrintWriter;
public class MessageUtil {
public static void alertError(Exception e, HttpServletResponse resp)throws IOException {
resp.setContentType("text/html;charset=UTF-8");
if(e instanceof CommonException commonException){ //밑 주석과 같은 기능...
// CommonException commonException = (CommonException)e;
resp.setStatus(commonException.getStatus()); //응답코드 400
}
//스크립트태그형태로 화면 출력
PrintWriter out = resp.getWriter();
out.printf("<script>alert('%s');</script>", e.getMessage());
}
}

추가

messgaeUtil

joinController

package org.choongang.global;
import jakarta.servlet.http.HttpServletResponse;
import org.choongang.global.exceptions.CommonException;
import java.io.IOException;
import java.io.PrintWriter;
public class MessageUtil {
public static void alertError(Exception e, HttpServletResponse resp) throws IOException {
resp.setContentType("text/html; charset=UTF-8");
if (e instanceof CommonException commonException) {
// CommonException commonException = (CommonException)e;
resp.setStatus(commonException.getStatus());
}
PrintWriter out = resp.getWriter();
out.printf("<script>alert('%s');</script>", e.getMessage());
}
public static void go(String url, String target, HttpServletResponse resp) throws IOException {
target = target == null || target.isBlank() ? "self" : target;
resp.setContentType("text/html; charset=UTF-8");
PrintWriter out = resp.getWriter();
/**
* location.href : 주소 이동시 이동 기록이 남는다, 뒤로가 버튼을 누른 경우
* POST 처리가 중복 된다.
* POST 처리시 이동할때는 기록을 남기지 않는 이동 방식 location.replace(..)
*/
//out.printf("<script>%s.location.href='%s';</script>", target, url);
out.printf("<script>%s.location.replace('%s');</script>", target, url);
}
public static void go(String url, HttpServletResponse resp) throws IOException {
go(url, "self", resp);
}
}
/*
회원가입 컨트롤러
*/
@WebServlet("/member/join")
public class JoinController extends HttpServlet { //상속받으면 얘는 서블릿 클래스가 되는거
//양식
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/templates/member/join.jsp"); //버퍼추가
rd.forward(req, resp); //버퍼치환
}
//처리
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
try{
JoinService joinService = MemberServiceProvider.getInstance().joinService();
//RequestJoin(DTO)에서 setter을 통해 데이터를 다 넣어주는건 번거롭다.. 수정수정
joinService.process(req); //사용자 요청을 process에 넘겨주기!
//요청데이터 들어오면 DTO로 변환작업
//자바스크립트 형태로 이동
go(req.getContextPath()+"/member/login","parent",resp);
//resp.sendRedirect(req.getContextPath()+"/member/login");
}catch(CommonException e){
alertError(e,resp);
}
}
}



로그인 하고 나서 이동 화면
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:url var="loginUrl" value="/member/login"/>
<c:url var="joinUrl" value="/member/join"/>
${member}
가입한거로 로그인 하면

이렇게 나옴
수정!!!

@WebServlet("/member/logout")
public class LogoutController extends HttpServlet {
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession();
//전체 세션 비우기
session.invalidate(); //로그아웃
resp.sendRedirect(req.getContextPath()+"/member/login");
}
}
로그아웃하면 로그인 창으로 돌아감
index.jsp에 반복되는 if문 대신 tag를 만들어서 사용하는게 좋다
커스텀 태그를 추가!

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ tag import="org.choongang.member.MemberUtil" %>
<%@ tag body-content="scriptless" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@ tag import= "org.choongang.member.MemberUtil" %>
<% if (MemberUtil.isLogin(request)){ %>
<jsp:doBody/>
<% } %>


<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="util" tagdir="/WEB-INF/tags/utils" %>
<c:url var="loginUrl" value="/member/login" />
<c:url var="joinUrl" value="/member/join" />
<c:url var="logoutUrl" value="/member/logout" />
<%-- 미로그인 상태일때 출력 --%>
<util:GuestOnly>
<a href="${loginUrl}">로그인</a>
<a href="${joinUrl}">회원가입</a>
</util:GuestOnly>
<%-- 로그인 상태일때 출력 --%>
<util:MemberOnly>
${sessionScope.member.userName}(${sessionScope.member.email})님 로그인...
<a href="${logoutUrl}">로그아웃</a>
</util:MemberOnly>
로그인 상태 EL식 속성, 회원정보 속성 -> 필터를 이용하여 모든 요청에 공유 가능하게 설정
CommonFilter


public class CommonRequestWrapper extends HttpServletRequestWrapper {
public CommonRequestWrapper(ServletRequest req) {
super((HttpServletRequest) req);
HttpServletRequest request = (HttpServletRequest) req;
/* 로그인 상태, 로그인 회원 정보 전역 유지 */
boolean isLogin = MemberUtil.isLogin(request);
Member member = MemberUtil.getMember(request);//로그인 회원정보
request.setAttribute("isLogin", isLogin);
request.setAttribute("loggedMember", member);
}
}
CommonFilter에 래퍼 추가

index.jsp 추가 수정

로그인 전

로그인 후

이메일 기억하기 기능 만들기
템플릿 login.jsp에서 체크박스 속성명이 saveEmail로 되어있음
LoginController


쿠키등록 완료! 기억하고있다 내 이메일!

값이 완성되어있고 체크도 그대로 되어있을것

체크 제거하면 쿠키도 제거될것이다!
(o゜▽゜)o☆