김영한 강사님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 듣고 정리한 내용입니다. 자세한 내용은 강의를 참고해주세요

Spring 첫 번째 강의에서 MVC , 도메인 , 서비스 , 레파지토리 + DB 이렇게 나뉜다고 배웠다.
지금까지 스프링을 공부하다 보니 SOLID의 5원칙 중 SRP를 가지고 기능에 맞게 패키지를 나눠나야지 -> 유지,보수적인 측면에서 유용하다는 것을 알게 되었다.
이번강의는 MVC강의이므로, MVC부분에 초점이 맞춰저 있어 레파지토리를 인터페이스와 구현체로 나누지 않고, 그냥 메모리에 저장하는 레파지토리 한개만 두었다(물론 db 없이 메모리에 저장)
아직 로그인 로직인 서비스 부분은 고려하이 않고, MVC와 도메인 부분을 봐보자!
도메인은 핵심 비지니스가 들어가는 부분이다
우리가 만든 컨트롤러,form,화면 ui같은 부분을 핵심 업무 도메인이라고 하지 않는다
즉 어느정도 분리가 되어있어야 한다.
우리가 향후 web기술을 다른 web기술로 바꾸거나
지금은 Form으로 데이터를 받고 있지만, api로 받는 구조로 코드를 바꿀때, 핵심 도메인 부분은 그대로 유지되어야 한다!!!
그럴려면
web부분은domain에 의존해도 되지만,domain은web부분에 의존하면 안된다
-> 단방향으로 코드를 설계해야 한다!!!
우리가 저번시간에 만든 코드를 볼 때, ItemSaveForm, ItemUpdateForm은 저장과 수정을 위한 객체이고
이를 바로 ItemRepository에 저장하는 것이 아니라, 생성자나 프로퍼티 접근법을 이용해서 domain 객체를 생성해 레파지토리에 저장했다
이렇게 의존관계를 잘 설정해주어야지 좋은 객체지향 프로그래밍이라고 할 수 있다.

@Bean Valdiation을 추가해준다(@NotEmpty)정도?
Optional<Memeber>로 반환해준다public Optional<Member>findByLoginId(String loginId){
return findAll().stream()
.filter(m -> m.getLoginId().equals(loginId))
.findFirst();
}
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add")
public String addForm(@ModelAttribute("member")Member member){
return "members/addMemberForm";
}
@PostMapping("/add")
public String save(@Validated @ModelAttribute("member") Member member, BindingResult bindingResult){
if (bindingResult.hasErrors()){
return "members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
get방식("/add")post방식("/add")ModelAttribute Member member로 form에서 전송받는 Memeber객체를 view로 이동@Validated로 검증을 한다bindingResult.hasErrors()면 다시 등록 화면으로 이동redirect:/한다/domain/login/LoginService 라고 만들어주자LoginService가 해주는 것이다@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginId,String password){
return memberRepository.findByLoginId(loginId)
.filter(m->m.getPassword().equals(password))
.orElse(null);
}
}
optional<Member> 객체를 찾고, 그 중에 비밀번호가 맞는지 확인을 한다web/login에 있는 loginForm이다@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
web/login/LoginController이다@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form){
return "login/loginForm.html";
}
@PostMapping("/login")
public String login(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult){
if (bindingResult.hasErrors()){
return "login/loginForm.html";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember==null){
bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
return "login/loginForm.html";
}
// 로그인 성공 TODO
return "redirect:/";
}
}
bindingResult.hasErrors()로@Bean Validator의 검정 오류가 있는지 확인을 한다Object Error이다Object Error는 자바 코드로 해주리고 했다.reject로 Object Error를 bindingResult에 추가해주고redirect:/된다그런데 우리는 홈 화면에 고객이름을 보여달라는 요구사항을 만족시키지 못했다
-> 쿠키로 이부분을 해결해보자

@PostMapping("/login")
public String login(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult, HttpServletResponse response){
if (bindingResult.hasErrors()){
return "login/loginForm.html";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember==null){
bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
return "login/loginForm.html";
}
// 로그인 성공 -> 로그인일 유지할 수 있는 쿠키를 만들어서 보내주자
// 쿠키에 시간정보를 넣어주지 않았으므로 -> [세션 쿠키]가 된다(브라우져 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
HttpServletRequest를 컨트롤러의 파라미터로 받는다String.valueOf()로 바꿔준다response에, addCookie를 해준다!
세션쿠키이기 때문에 브라우저가 끊기면 쿠키도 없어지게 된다1.세션 쿠키이므로 웹 브라우저 종료시
2.서버에서 해당 쿠키의 종료 날짜를 0으로 지정
setMaxAge(0)을 이용해 쿠키의 세션을 만료시킬 것이다@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
쿠키를 사용해서 로그인Id를 전달해서 로그인을 유지할 수 있었다. 그런데 여기에는 심각한 보안 문제가 있다.
개발자모드 Application Cookie 변경으로 확인
먼저 서버에 로그인을 성공적으로 한다

성공적으로 로그인이 됐으면, 서버는 서버 메모리(저장소)에, 세션 Id를 생성하고 value값에 값을 보관한다
세션 ID를 생성하는데, 추정 불가능해야 한다.
(UUID는 추정이 불가능하다.)
생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

저장한 Session ID를 Set-Cookkie로 쿠키에 담아서 전달한다
이때, 클라이언트는 쿠키 저장소에서 받은 Cookie즉 sessionId를 보관한다
중요한점은, 회원과 관련된 정보가 서버에서 클라이언트로 전달되지 않는 다는 것이다
추적 블가능한 sessionId만이 쿠키를 통해서, 서버에서 클라이언트로 전송된다

@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 생성
*/
public void createSession(Object value, HttpServletResponse response){
// sessionId 생성하고 값을 세션에 저장 (임의의 추정 불가능한 랜덤 값) -> [UUID]
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId,value);
// 쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
ConcurrentHashMap으로 내부 메모리 저장소를 구현한다서버에서 -> 클라이언트로가기 때문에, HttpServletRequest를 객체로 받는다UUID를 이용해 랜덤 아이디를 만들고put으로 저장한다HttpServletRequest에 addCookie로 쿠키를 추가한다음 응답한다 /**
* 세션 조회
*/
public Object getSession(HttpServletRequest request){
Cookie seesionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (seesionCookie==null){
return null;
}
return sessionStore.get(seesionCookie.getValue());
}
public Cookie findCookie(HttpServletRequest request,String cookieName){
if (request.getCookies() == null){
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(SESSION_COOKIE_NAME))
.findAny()
.orElse(null);
}
HttpServletRequest에서 cookie들 중에, 우리가 등록한 쿠키의 이름과 같은 것을 반환한다 /**
* 세션 만료
*/
public void expire(HttpServletRequest request){
Cookie seesionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (seesionCookie !=null) {
sessionStore.remove(seesionCookie.getValue());
}
}
HttpSession이라는 기능을 제공한다HttpSession을 생성하면 다음과 같은 쿠키를 생성JESSIONID@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult, HttpServletRequest request){
if (bindingResult.hasErrors()){
return "login/loginForm.html";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember==null){
bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
return "login/loginForm.html";
}
// 로그인 성공
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession(true);
// 세션에 로그인 회원 정보를 보관
session.setAttribute(SessionConst.LOGIN_NUMBER, loginMember);
return "redirect:/";
}
getSession()메서들을 이용해 HttpSession객체를 만들 수 있다true)false)중요한 것은, 처음 로그인을 할 때, request객체에서
getSession(true)를 이용해 ->HttpSession객체를 만든다는 것이다
다음은 로그아웃 버튼이다
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session !=null){
session.invalidate();
}
return "redirect:/";
}
getSession(flase)로 로그인 성공시 만들어진 HttpSession 객체를 찾은 후invalidate() 메서드를 이용해 세션을 제거한다마지막으로 홈 화면이다
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model){
HttpSession session = request.getSession(false);
if (session==null){
return "home";
}
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_NUMBER);
// 세션에 회원 데이터가 없으면 home
if (loginMember == null){
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
session.getAttribute()를 이용해, 로그인이 성공했다면 Session 저장소에 보관된 값을 찾는다Object반환이므로 캐스팅을 해주고Model객체에 addAttribute해서 컨트롤러에서 뷰로 데이터를 전달해준다@SessionAttribute를 지원한다@SessionAttribute는 Session 저장소에서 이미 저장된 객체 값을 찾을 때 사용하는 에노테이션이다@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_NUMBER,required = false) Member loginMember
, Model model){
// 세션에 회원 데이터가 없으면 home
if (loginMember == null){
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
}
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
server.servlet.session.tracking-modes=cookie를 넣어준다
sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)creationTime : 세션 생성일시lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId( JSESSIONID )를 요청해서 조회된 세션인지 여부session.invlaidate()를 직접 호출하는 경우다더 좋은 대안은
세션 생성 시점이 아니라사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것
이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.
따라서 30분 마다 로그인해야 하는 번거로움이 사라진다.HttpSession은 이 방식을 사용한다.
server.servlet.session.timeout=60 : 60초, 기본은 1800(30분)를 추가하면 된다session.lastAccessedTime을 이용하는 것이다timeout이 지나면, WAS는 내부에서 해당 세션을 제거하고 로그 아웃이 된다실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다