[스프링부트] 19. Blog v2 : User

문정준's avatar
Apr 04, 2025
[스프링부트] 19. Blog v2 : User

1. 회원가입

  • User
package shop.mtcoding.blog.user; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import java.sql.Timestamp; @NoArgsConstructor @Getter @Table(name = "user_tb") @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(unique = true) private String username; private String password; private String email; @CreationTimestamp private Timestamp createdAt; @Builder public User(Integer id, String username, String password, String email, Timestamp createdAt) { this.id = id; this.username = username; this.password = password; this.email = email; this.createdAt = createdAt; } @Override public String toString() { return "User{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", email='" + email + '\'' + ", createdAt=" + createdAt + '}'; } }
  • UserRequest
package shop.mtcoding.blog.user; import lombok.AllArgsConstructor; import lombok.Data; public class UserRequest { @AllArgsConstructor @Data public class JoinDTO { private String username; private String password; private String email; public User toEntity() { return User.builder().username(username).password(password).email(email).build(); } } @Data public class LoginDTO { private String username; private String password; public User toEntity() { return User.builder().username(username).password(password).build(); } } }
  • UserController
@GetMapping("/join-form") public String joinForm(){ return "user/join-form"; } @PostMapping("/join") public String join(UserRequest.JoinDTO joinDTO) { userService.회원가입(joinDTO); return "redirect:/login-form"; }
  • UserService
@Transactional public void 회원가입(UserRequest.JoinDTO joinDTO) { User user = joinDTO.toEntity(); userRepository.insertUser2(user); // user 객체 : 영속 & 동기화됨 }
  • UserRepository
public void insertUser(User user) { Query q = em.createNativeQuery("insert into user_tb(username, password, email, created_at) values(?, ?, ?, now());"); q.setParameter(1, user.getUsername()); q.setParameter(2, user.getPassword()); q.setParameter(3, user.getEmail()); q.executeUpdate(); } // 2. User 영속 public void insertUser2(User user) em.persist(user); } // 3. User는 DB와 동기화
notion image
 

예외 처리 1. 아이디 중복체크

  • 아이디가 중복될 경우 가입이 불가하도록 조치
 
  • join-form.mustache
    • 중복 확인 : permitRegister 함수 작동
    • permitRegister()
      • username을 받아온 후, DB에서 중복된 데이터가 있는지 체크
        • JSON 데이터를 요청 (”/permit-register/"+username) : GET 요청
        • 받은 JSON 데이터를 JS Object 데이터로 파싱
        • 내부 available 값에 따라 가입 허가 (true면 회원가입 가능)
    • username 입력 칸에 keyup 이벤트 리스너 추가
      • username 내 키 입력 감지 시 체크 변수인 isPermitted = false
    • submit 시 valid 체크 : isPermitted가 true이면 가입 (/join으로 POST 요청)
      • false일 시 alert 출력
{{> layout/header}} <div class="container p-5"> <!-- 요청을 하면 localhost:8080/join POST로 요청됨 username=사용자입력값&password=사용자값&email=사용자입력값 --> <div class="card"> <div class="card-header"><b>회원가입을 해주세요</b></div> <div class="card-body"> <form action="/join" method="post" enctype="application/x-www-form-urlencoded" onsubmit="return valid()"> <div class="mb-3"> <input type="text" class="form-control" placeholder="Enter username" name="username" id="username"> <button type="button" class="btn btn-warning" onclick="permitRegister()">중복확인</button> </div> <div class="mb-3"> <input type="password" class="form-control" placeholder="Enter password" name="password"> </div> <div class="mb-3"> <input type="email" class="form-control" placeholder="Enter email" name="email"> </div> <button type="submit" class="btn btn-primary form-control">회원가입</button> </form> </div> </div> </div> <script> let isPermitted = false; let usernameInput = document.querySelector("#username"); usernameInput.addEventListener("keyUp",()=>{ isPermitted = false; }); function valid() { if(!isPermitted) { alert("아이디 중복체크를 해주세요."); usernameInput.readOnly = false; return false; } return true; } async function permitRegister() { let username = usernameInput.value; let response = await fetch("/permit-register/"+username); let responseBody = await response.json(); // status = 200, msg = '성공', body : { available = true } isPermitted = responseBody.body.available; if(isPermitted) { alert("아이디 사용이 가능합니다."); // usernameInput.readOnly = true; } else { alert ("존재하는 아이디가 있습니다. 다시 입력해주세요."); } } </script> {{> layout/footer}}
 
  • UserController
    • Resp 객체를 반환 : JSON 형태
    • @ResponseBody : Controller 내에서 데이터를 반환할 때 사용
      • 보통 APIController를 따로 만들어서 @RestController 속성을 부여한 후 사용
    • 존재 여부만 확인하므로 DTO를 따로 만들지 않고, Map 컬렉션을 활용
@GetMapping("/permit-register/{username}") public @ResponseBody Resp<?> permitRegister(@PathVariable("username") String username) { Map<String, Object> dto = userService.유저네임중복체크(username); return Resp.ok(dto); }
 
  • Resp
    • 응답 형태에 따라 각기 다른 JSON을 반환하기 위한 Response
    • 제네릭 클래스를 활용하여 body의 타입에 따라 동적으로 타입 부여
      • new 객체 생성을 피하기 위한 static 선언
        • 제네릭 클래스의 특징인 동적 타입 바인딩에 반하는 특성 : 오류 발생
        • 예외 문법 : <T> 타입을 제네릭 클래스 앞에 붙임 : Object 타입으로 데이터를 받음
          • 나중에 body가 들어오면 그 타입에 맞추어서 자동으로 다운캐스팅
package shop.mtcoding.blog._core; import lombok.Data; @Data public class Resp<T> { private Integer status; private String msg; private T body; private Resp(Integer status, String msg, T body) { this.status = status; this.msg = msg; this.body = body; } public static <T> Resp<?> ok(T body) { return new Resp<>(200, "성공", body); } public static Resp<?> fail(Integer status, String msg) { return new Resp(status, msg, null); } }
 
  • UserService
    • username을 통해 해당하는 유저가 있는지 탐색
    • Map 컬렉션 생성 : body에 key : value 꼴의 HashMap 삽입
      • user가 없으면 (중복 유저가 없음) available : true 삽입 후 반환
      • user가 존재하면 available : false 삽입 후 반환
public Map<String, Object> 유저네임중복체크(String username) { User user = userRepository.findByUsernameV2(username); Map<String, Object> dto = new HashMap<>(); if(user == null) dto.put("available", true); else dto.put("available", false); return dto; }
 

결과

  • 중복되는 username이 존재할 경우, alert 출력
notion image
 
  • username이 중복되지 않으면, 아이디 사용이 가능하다는 alert 출력
notion image
 
  • 중복확인을 누르지 않고 회원가입 시, 아이디 중복체크 확인을 하라는 alert 출력
notion image
 
  • 중복 확인 후 중복되는 아이디로 사용자가 값을 변경 후 회원가입을 하게 되면, Exception 발생
    • 에러 메시지 직접 출력으로 stacktrace 비출력
notion image
  • 이후 UX를 위해서, 아이디 중복확인 후 해당 입력창 속성을 readOnly로 변경할 수 있음
    • 정상적 접근의 경우 중복 확인이 된 후 아이디를 변경할 수 없음
    • 그럼에도 값을 변경하는 경우, bad request이므로 이에 대한 추가 조치 가능
 

2. 로그인

  • UserController
@GetMapping("/login-form") public String loginForm() { return "user/login-form"; } @PostMapping("/login") public String login(UserRequest.LoginDTO loginDTO, HttpSession session) { User user = userService.로그인(loginDTO); session.setAttribute("validatedUser", user); // System.out.println(session.getAttribute("validatedUser")); return "redirect:/"; }
  • UserService
public User 로그인(UserRequest.LoginDTO loginDTO) { User user = loginDTO.toEntity(); User foundUser = userRepository.findByUsernameV2(user); if (foundUser == null) throw new RuntimeException("일치하는 유저가 없습니다."); if(!(foundUser.getPassword().equals(loginDTO.getPassword()))) throw new RuntimeException("아이디 또는 비밀번호가 틀립니다."); return foundUser; }
  • UserRepository
public User findByUsernameV2(User user) { Query q = em.createQuery("select u from User u where u.username = :username", User.class); q.setParameter("username", user.getUsername()); return (User) q.getSingleResult(); }
// JPA public User findByUsernameV3(User user) { return em.find(User.class, user.getUsername()); }
notion image
  • session 정보 (User.toString())
notion image
 

예외 처리 1. Username 저장

  • 체크박스 체크 후 로그인을 하면, 이후 다시 로그인을 할 때에도 아이디가 자동 작성되도록 설정
 
  • login-form.mustache
    • checkbox의 체크 여부에 따라 값을 저장할 지 결정해야 함 : checkbox의 값을 받아와야 함
      • 체크가 되어 있으면 “on”, 되어있지 않으면 null 반환
      • loginDTO에 저장
    • 브라우저의 cookie 탐색 : “username” = value 꼴의 쿠키 탐색
    • 쿠키가 존재해서 username ≠ null 일 경우 : value 대입
{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>로그인을 해주세요</b></div> <div class="card-body"> <form action="/login" method="post" enctype="application/x-www-form-urlencoded"> <div class="mb-3"> <input type="text" class="form-control" placeholder="Enter username" name="username" id="username"> </div> <div class="mb-3"> <input type="password" class="form-control" placeholder="Enter password" name="password"> </div> <!-- ✅ 공개 여부 체크박스 --> <div class="form-check mb-3"> <input class="form-check-input" type="checkbox" name="rememberMe" id="rememberMe" checked> <label class="form-check-label" for="isUsernameCheck"> 아이디를 기억하겠습니까? </label> </div> <button type="submit" class="btn btn-primary form-control">로그인</button> </form> </div> </div> </div> <script> let username = getCookie("username"); if (username != null) document.querySelector("#username").value = username; function getCookie(key) { const cookies = document.cookie.split('; '); for (let cookie of cookies) { const [k, v] = cookie.split('='); if (k == key) { return v; } } return null; // 해당 키가 없으면 null 반환 } </script> {{> layout/footer}}
 
  • UserController
    • 체크박스 여부에 따라 cookie의 만료 기간 및 유효 Path를 설정
    • cookie.setPath(”/”) : 사이트 전역에서 쿠키 접근이 가능
    • cookie.setMaxAge(int) : 쿠키의 만료 시간 설정 (seconds)
      • 0일 경우 즉시 만료 : 바로 삭제됨
    • response에 쿠키 추가
public String login(UserRequest.LoginDTO loginDTO, HttpSession session, HttpServletResponse response) { User user = userService.로그인(loginDTO); session.setAttribute("validatedUser", user); // System.out.println(session.getAttribute("validatedUser")); if (loginDTO.getRememberMe() == null) { Cookie cookie = new Cookie("username", null); cookie.setMaxAge(0); // 0으로 설정하면 즉시 삭제 cookie.setPath("/"); // 경로를 명확히 지정해야 삭제 가능 response.addCookie(cookie); } else { Cookie cookie = new Cookie("username", loginDTO.getUsername()); cookie.setMaxAge(60 * 60 * 24 * 7); cookie.setPath("/"); response.addCookie(cookie); } return "redirect:/"; }
 
  • UserRequest
    • String rememberMe : 체크박스 여부에 따라 on 또는 null 저장
@Data public class LoginDTO { private String username; private String password; private String rememberMe; // on if checked, else null public User toEntity() { return User.builder().username(username).password(password).build(); } }
 

3. 로그아웃

  • UserController
@GetMapping("/logout") public String logout(HttpSession session) { session.invalidate(); return "redirect:/"; }
 

4. Layout : Header

<!DOCTYPE html> <html lang="en"> <head> <title>Blog</title> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <style> .my-like-heart { font-size: 24px; color: gray; cursor: pointer; } .my-like-heart.liked { color: red; } </style> </head> <body> <nav class="navbar navbar-expand-sm bg-dark navbar-dark"> <div class="container-fluid"> <a class="navbar-brand" href="/">Metacoding</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collapsibleNavbar"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="collapsibleNavbar"> <ul class="navbar-nav"> {{#validatedUser}} <li class="nav-item"> <a class="nav-link" href="/board/save-form">글쓰기</a> </li> <li class="nav-item"> <a class="nav-link" href="/user/update-form">회원정보보기</a> </li> <li class="nav-item"> <a class="nav-link" href="/logout">로그아웃</a> </li> {{/validatedUser}} {{^validatedUser}} <li class="nav-item"> <a class="nav-link" href="/join-form">회원가입</a> </li> <li class="nav-item"> <a class="nav-link" href="/login-form">로그인</a> </li> {{/validatedUser}} </ul> </div> </div> </nav>
 

5. 회원정보 수정

  • BoardController
    • 글을 작성하고 글쓰기완료 버튼을 누르면 session 정보와 DTO를 전송
@GetMapping("/board/save-form") public String saveForm(HttpSession session) { User validatedUser = (User) session.getAttribute("validatedUser"); if (validatedUser == null) throw new RuntimeException("인증이 필요합니다."); return "board/save-form"; } @PostMapping("/board/save") public String save(BoardRequest.SaveDTO saveDTO, HttpSession session) { User validatedUser = (User) session.getAttribute("validatedUser"); if (validatedUser == null) throw new RuntimeException("인증이 필요합니다."); boardService.글쓰기(saveDTO, validatedUser); return "redirect:/"; }
 
  • BoardRequest
    • user 정보를 세션에서 받아옴
@Data public static class SaveDTO { private String title; private String content; private String isPublic; public Board toEntity(User user) { return Board.builder() .title(title) .content(content) .isPublic(isPublic == null ? false : true) .user(user) .build(); } }
 
  • BoardService
@Transactional public void 글쓰기(BoardRequest.SaveDTO saveDTO, User user) { Board board = saveDTO.toEntity(user); boardRepository.write(board); }
 
  • BoardRepository
public void write(Board board) { em.persist(board); }
 
  • user/update-form.mustache
{{> layout/header}} <div class="container p-5"> <div class="card"> <div class="card-header"><b>회원수정을 해주세요</b></div> <div class="card-body"> <form action="/user/update" method="post" enctype="application/x-www-form-urlencoded"> <div class="mb-3"> <input value="{{validatedUser.username}}" type="text" class="form-control" placeholder="Enter username" disabled> </div> <div class="mb-3"> <input type="password" class="form-control" placeholder="Enter password" name="password"> </div> <div class="mb-3"> <input value="{{validatedUser.email}}" type="email" class="form-control" placeholder="Enter email" name="email"> </div> <button type="submit" class="btn btn-primary form-control">회원가입수정</button> </form> </div> </div> </div> {{> layout/footer}}
 

결과

  • 회원 정보 수정 반영
notion image
notion image
 
Share article

sxias