본문 바로가기

SpringBoot

[연재] Spring Boot diary : study.diary 멤버 가입

728x90
반응형

원문은 Spring Boot: study.diary 멤버 가입을 참고하세요.

 

데이터베이스에 사용자 테이블 member 를 추가

-- 사용자 테이블 추가
CREATE TABLE public."member" (
                                 email varchar(255) NOT NULL,
                                 "password" varchar(255) NOT NULL,
                                 "name" varchar(255) NOT NULL,
                                 CONSTRAINT member_pk PRIMARY KEY (email)
);
COMMENT ON TABLE public."member" IS '사용자';

-- Column comments

COMMENT ON COLUMN public."member".email IS '이메일주소';
COMMENT ON COLUMN public."member"."password" IS '패스워드';
COMMENT ON COLUMN public."member"."name" IS '사용자이름';


일기데이터 테이블(diary) 에도 누가 작성한 일기인지를 알 수 있도록 email 컬럼을 추가

ALTER TABLE public.diary ADD email varchar(255) NULL;

Member 클래스에 멤버를 추가

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Member {
    private String email;
    private String password;
    private String name;
}

Diary 클래스에도 멤버를 추가

@Data
public class Diary {
    private Integer id;
    private LocalDate diary_date;
    private String diary_content;
    private String email;
}

member 테이블에 대한 기본적인 CRUD 매퍼를 작성

MemberMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.woohahaapps.study.diary.mapper.MemberMapper">
    <insert id="CreateMember">
        insert into member (email, password, name) values (#{email}, #{password}, #{name});
    </insert>
    <select id="GetMember" resultType="com.woohahaapps.study.diary.domain.Member">
        select
            *
        from member
        where email=#{email};
    </select>
    <update id="UpdateMember">
        update diary
        set
            password = #{password}
          , name = #{name}
        where
            email = #{email};
    </update>
    <delete id="DeleteMember">
        delete from member
        where
            email = #{email};
    </delete>
    <select id="GetAllMembers" resultType="hashmap">
        select
            *
        from member
        order by
            email
    </select>
</mapper>

member 에 대한 매퍼 인터페이스 작성

MemberMapper.java
package com.woohahaapps.study.diary.mapper;

import com.woohahaapps.study.diary.domain.Member;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;
import java.util.Map;

@Mapper
public interface MemberMapper {
    public void CreateMember(String email, String password, String name);

    public Optional<Member> GetMember(String email);

    public void UpdateMember(String email, String password, String name);

    public void DeleteMember(String email);

    List<Map<String, Object>> GetAllMembers();
}

매퍼 환경(mybatis-config.xml) 수정

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <mappers>
        <mapper resource="mapper/DiaryMapper.xml"/>
        <mapper resource="mapper/MemberMapper.xml"/>
    </mappers>
</configuration>

member 테이블과 Member 에 대한 매퍼 인터페이스가 만들어졌으니 LoginService 의 loadUserByUsername 에 작성해두었던 임시코드를 수정할 수가 있어.

파라미터로 받고 있는 username 은 이름은 username 이지만 실제로는 로그인폼에 입력한 사용자 이메일 주소야. 아래와 같이 수정했어.

LoginService.java
@Service
public class LoginService implements UserDetailsService {

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    private final MemberMapper memberMapper;

    public LoginService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberMapper.GetMember(username).orElseThrow();
        return new User(member);
    }
}

사용자가입 기능을 구현

login.html 을 복사해서 회원가입용 폼을 작성 (signup.html)

signup.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.tyhmeleaf.org">
<head>
    <meta charset="UTF-8">

    <!-- 모바일에서의 적절한 반응형 동작을 위해 -->
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- -->


    <title>Sign up</title>

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
          crossorigin="anonymous"
    >
    <!-- -->

    <!-- head 태그에 다음의 코드를 삽입 -->
    <style type="text/css">

        html,
        body {
            height: 100%;
        }

        .form-signup {
            max-width: 330px;
            padding: 1rem;
        }

        .form-signup .form-floating:focus-within {
            z-index: 2;
        }

        .form-signup input[type="email"] {
            margin-bottom: -1px;
            border-bottom-right-radius: 0;
            border-bottom-left-radius: 0;
        }

        .form-signup input[type="password"] {
            margin-bottom: -1px;
            border-top-left-radius: 0;
            border-top-right-radius: 0;
            border-bottom-right-radius: 0;
            border-bottom-left-radius: 0;
        }

        .form-signup input[type="text"] {
            margin-bottom: 10px;
            border-top-left-radius: 0;
            border-top-right-radius: 0;
        }

    </style>

</head>
<body>

<main class="form-signup w-100 m-auto">
    <!--    <div th:if="${param.error}" class="alert alert-danger" role="alert">Invalid username and password.</div>-->
    <!--    <div th:if="${param.logout}" class="alert alert-primary" role="alert">>You have been logged out.</div>-->
    <form th:action="@{/signup}" method="POST">
        <h1 class="h3 mb-3 fw-normal">Please sign up</h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="floatingEmail" name="email" placeholder="name@example.com">
            <label for="floatingEmail">Email address</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password">
            <label for="floatingPassword">Password</label>
        </div>
        <div class="form-floating">
            <input type="text" class="form-control" id="floatingName" name="name" placeholder="Username">
            <label for="floatingName">Username</label>
        </div>

        <button class="btn btn-primary w-100 py-2" type="submit">Sign up</button>
        <p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
    </form>
</main>

<!-- Popper -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
        crossorigin="anonymous"></script>

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
        integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
        crossorigin="anonymous"></script>

</body>
</html>

MemberController 생성

MemberController.java
package com.woohahaapps.study.diary.controller;

import com.woohahaapps.study.diary.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class MemberController {

    private final MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/signup")
    public String signUp(Model model) {
        return "signup";
    }

    @PostMapping("/signup")
    public String signUp(String email, String password, String name) {
        memberService.signUp(email, password, name);
        return "redirect:/";
    }
}

MemberService 생성

MemberService.java
package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.mapper.MemberMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class MemberService {
    private final MemberMapper memberMapper;

    @Autowired
    public MemberService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }


    public void signUp(String email, String password, String name) {
        memberMapper.CreateMember(email, password, name);
    }
}

SecurityConfig 수정

SecurityConfig.java
...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/process_login", "/signup").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .loginProcessingUrl("/process_login")
                        .usernameParameter("email")
                        .permitAll()
                )
                .build();
    }
...

실행시켜서 http://localhost:8080/signup 으로 이동하면 회원가입 화면이 표시되고, email, password, name 을 입력하고 Sign up 버튼을 누르면 member 테이블에 데이터가 입력되는 것을 확인할 수 있어.

 


그런데 password 가 입력한 값 그대로 저장되니 로그인할 때 Encoded password does not look like BCrypt 오류가 표시되네.

사용자가 입력한 패스워드값을 암호화해서 저장되도록 기능을 수정해볼께.

이미 SecurityConfig 에서 PasswordEncoder 를 Bean 으로 등록시켜두었으니 패스워드를 암호화할 곳에서 주입받아 사용하기만 하면 돼. 적절한 위치가 MemberService 야.

email 중복에 대한 처리가 아직 없기 때문에 member 테이블을 truncate 시키고 다시 해보자.


바로 로그인도 해보면 패스워드 암호화/복호화가 성공하고 있는 것을 확인할 수가 있어.

이제 동일한 이메일 가입에 대한 처리를 진행해볼께.

이미 가입되어 있는 이메일과 동일한 이메일로 member 테이블에 insert 를 할 때 아래와 같이 에러가 표시되고 있지.

MemberMapper 에서 이미 등록된 이메일 주소가 있을 경우 Exception 을 발생시키고 있어. 이 Exception 을 처리해서 중복 이메일이 등록되어 있는 것을 표시해보도록 할께.

우선 MemberService 에서 Exception 을 처리해보자.

MemberService.java
...
    public void signUp(String email, String password, String name) throws MemberException {
        try {
            memberMapper.CreateMember(email, passwordEncoder.encode(password), name);
        } catch (Exception e) {
            System.out.println(e.getClass().getName());// org.springframework.dao.DuplicateKeyException
            if (e.getClass().getName().toLowerCase().contains("duplicatekey")){
                throw new MemberException("이메일주소가 이미 등록되어 있습니다: " + email);
            }
        }
    }

발생되는 Exception 의 class 이름을 로깅해보면 org.springframework.dao.DuplicateKeyException 인 것을 알 수가 있는데, Exception class 이름에 duplicatekey 가 포함되어 있는 경우 사용자정의 Exception 인 MemberException 을 던지도록 수정했어.

MemberException 은 RuntimeException 을 상속받아서 아래와 같이 정의했지.

package com.woohahaapps.study.diary.exception;

public class MemberException extends RuntimeException {
    public MemberException() {}
    public MemberException(String message) {
        super(message);
    }
}

MemberController 의 signUp 함수에서 MemberException 을 처리하도록 했어.

    @PostMapping("/signup")
    public String signUp(String email, String password, String name, Model model) {
        try {
            memberService.signUp(email, password, name);
        } catch (MemberException e) {
            log.error(e.getMessage());
            model.addAttribute("error", e.getMessage());
            return "signup";
        } catch (Exception e) {
            log.error(e.getMessage());
            model.addAttribute("exception", e.getMessage());
            return "signup";
        }
        return "redirect:/";
    }

signup.html 에는 “error” 변수와 “exception” 변수에 대한 처리 코드를 추가했지.

<main class="form-signup w-100 m-auto">
    <div th:if="${error}" class="alert alert-danger" role="alert" th:text="${error}"></div>
    <div th:if="${exception}" class="alert alert-danger" role="alert" th:text="${exception}"></div>
    <form th:action="@{/signup}" method="POST">
...

이제 이미 가입된 이메일 주소로 가입하는 경우에는 에러메시지가 추가로 표시되면서 signup.html 화면이 표시되는거야.

이번에는 필수값에 대한 처리 방법을 확인해볼께. email, password, username 이 모두 데이터베이스에 필수값으로 구성되어 있는데, UI 에서 값을 입력하지 않더라도 공백문자열이 전달되어서 DB에 공백값으로 저장될 수 있어.

우선 build.gradle 에 validation 의존성 패키지를 추가해 주어야 해.

implementation 'org.springframework.boot:spring-boot-starter-validation'

Member 클래스의 멤버변수에 유효성 검사를 위한 애노테이션을 붙여볼께.

public class Member {
    @NotBlank(message = "이메일 주소는 필수값입니다.")
    private String email;
    @NotBlank(message = "패스워드는 필수값입니다.")
    private String password;
    @NotBlank(message = "이름은 필수값입니다.")
    private String name;
}

MemberController 의 signUp (POST method) 함수가 이메일주소, 패스워드, 이름을 각각 파라미터로 받아서 처리하고 있는데, Member 객체를 받아서 처리하도록 수정해볼께. 동시에 입력값의 유효성을 체크하는 로직도 함께 추가할거야.

    @PostMapping("/signup")
    //public String signUp(String email, String password, String name, Model model) {
    public String signUp(@Valid Member member, BindingResult result, Model model) {
        if (result.hasErrors()) {
            return "signup";
        }
        try {
            memberService.signUp(member.getEmail(), member.getPassword(), member.getName());
        } catch (MemberException e) {
            log.error(e.getMessage());
            model.addAttribute("error", e.getMessage());
            return "signup";
        } catch (Exception e) {
            log.error(e.getMessage());
            model.addAttribute("exception", e.getMessage());
            return "signup";
        }
        return "redirect:/";
    }

여기까지 수정된 상태에서는 회원가입 폼에서 아무것도 입력하지 않고 sign up 버튼을 클릭하더라도 signup 화면만 다시 표시가 될 뿐이야. signup.html 에서 thymeleaf 코드를 수정해 주어야 해.

<main class="form-signup w-100 m-auto">
    <div th:if="${error}" class="alert alert-danger" role="alert" th:text="${error}"></div>
    <div th:if="${exception}" class="alert alert-danger" role="alert" th:text="${exception}"></div>
    <form th:action="@{/signup}" th:object="${member}" method="POST">
        <h1 class="h3 mb-3 fw-normal">Please sign up</h1>

        <div class="form-floating">
            <input type="email" th:field="*{email}" class="form-control" th:class="${#fields.hasErrors('email')} ? 'form-control fieldError' : 'form-control'" placeholder="name@example.com" />
            <label th:for="email">Email address</label>
            <p th:text="${#fields.hasErrors('email')}" th:errors="*{email}">Incorrect data</p>
        </div>
        <div class="form-floating">
            <input type="password" th:field="*{password}" class="form-control" th:class="${#fields.hasErrors('password')} ? 'form-control fieldError' : 'form-control'" placeholder="Password">
            <label th:for="password">Password</label>
            <p th:text="${#fields.hasErrors('password')}" th:errors="*{password}">Incorrect data</p>
        </div>
        <div class="form-floating">
            <input type="text" th:field="*{name}" class="form-control" th:class="${#fields.hasErrors('name')} ? 'form-control fieldError' : 'form-control'" placeholder="Username">
            <label th:for="name">Username</label>
            <p th:text="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect data</p>
        </div>

        <button class="btn btn-primary w-100 py-2" type="submit">Sign up</button>
        <p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
    </form>
</main>

signup.html 은 member Object 를 전달받아야 하므로 @GetMapping 애노테이션을 붙인 함수에서 멤버 오브젝트를 주입해 주어야 한다.

    @GetMapping("/signup")
    public String signUp(Model model) {
        model.addAttribute("member", new Member());
        return "signup";
    }

이제 UI 계층에서 필수 입력값 체크 로직이 완성되었다. Rest API 단에서도 필수 입력값 체크 로직이 추가되면 더 좋겠다.

MemberService.java
...
    public void signUp(String email, String password, String name) throws MemberException {
        if (email.isBlank() || password.isBlank() || name.isBlank()) {
            throw new MemberException("필수입력값이 없습니다: " + (email.isBlank() ? "email " : "") + (password.isBlank() ? "password " : "") + (name.isBlank() ? "name " : ""));
        }
        try {
            memberMapper.CreateMember(email, passwordEncoder.encode(password), name);
        } catch (Exception e) {
            System.out.println(e.getClass().getName());// org.springframework.dao.DuplicateKeyException
            if (e.getClass().getName().toLowerCase().contains("duplicatekey")){
                throw new MemberException("이메일주소가 이미 등록되어 있습니다: " + email);
            }
        }
    }
반응형