본문 바로가기
프로젝트/아리(Ari)

[Spring, Data JPA] 회원가입

by sepang 2022. 9. 2.

  이전까지는 혼자서 간단한 수준의 프로젝트를 따라만 했었다. 이제는 직접 프로젝트를 구상하고 구현해봐야 겠다고 생각했고, 다행이 기회가 되어 학교에서 지원금을 받고 진행하는 프로젝트 과목에 팀을 꾸려서 참여하게 되었다. 나는 백엔드 역할을 담당한다. spring boot, jpa(spring data jpa)를 통해 서버를 구축하고 아마존 EC2/RDS를 통해 직접 배포하고 운영까지 짧게 해볼 생각이다.

  진행하는 프로젝트는 대학 상권 가게들의 할인 이벤트 등을 지도 api에 시각화, 그리고 가게별 제휴 이벤트 구성을 위한 게시판 및 채팅 서비스를 구현하는 것을 우선적인 목표로 하고 있다.

  7월부터 스프링 공부를 시작하고 프로젝트를 조금씩 진행하였지만 게으름 때문에 이제야 처음부터 조금씩 개발기록들을 써내려갈 생각이다. 배우게 되는 기능이나 개념에 대해 깊이 공부하는게 좋지만 시간이 촉박한 프로젝트이기에 일단은 기능 구현 위주로 진행할 것 같다. 우선 내가 담당한 기능 중 첫번째인 자체 회원가입/로그인부터 시작해보자.

 

응답 방법

  이것이 정석적인 방법인지는 모르겠으나 당분간은 다음과 같이 응답하고자 한다.

@Component
public class Response {

    @Getter
    @Builder
    private static class Body {

        private int state;
        private String result;
        private String massage;
        private Object data;
        private Object error;
    }

    public ResponseEntity<?> success(Object data, String msg, HttpStatus status) {
        Body body = Body.builder()
                .state(status.value())
                .data(data)
                .result("success")
                .massage(msg)
                .error(Collections.emptyList())
                .build();
        return ResponseEntity.ok(body);
    }
    
    public ResponseEntity<?> fail(Object data, String msg, HttpStatus status) {
        Body body = Body.builder()
                .state(status.value())
                .data(data)
                .result("fail")
                .massage(msg)
                .error(Collections.emptyList())
                .build();
        return ResponseEntity.ok(body);
    }
 
                        /************* 이후 생략 ***************/

 

fig 3. 응답 방식

  Response 클래스는 fig 3 같은 정보를 담는 Body 객체를 반환하는 함수들로 이루어져있다.

 

회원가입

fig 4-1
fig 4-2
fig 4-3

  우리 서비스는 서비스를 이용하는 user 계정과 가게 사장님이 사용할 owner 계정까지 해서 두 종류의 계정을 사용할 것 이다. 공통된 정보를 받고 owner 계정은 기본 가게정보를 추가적으로 받기 때문에 owner 계정 기준으로 회원가입을 살펴보자. fig 4-2 페이지를 통해 회원가입 정보를 받는데, 프론트엔드 팀원이 입력 형식에 맞게 입력해야 버튼이 활성화 되도록 처리해줬다. 그럼에도 서버 쪽에서도 이후 검증 단계가 필요할 것 같다. 어쨌든 컨트롤러부터 시작해서 하나씩 내려가보자.

DTO

fig 4-4

@Getter
@RequiredArgsConstructor
public class SignupDto {

    @NotBlank(message = "아이디를 입력해주세요")
    @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{5,30}$", message = "아이디는 특수문자를 제외한 5자이상이여야 합니다")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요")
    @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z]).{8,16}", message = "최소 하나의 문자 및 숫자를 포함한 8~16자이여야 합니다")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요")
    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다")
    private String email;

    @NotBlank(message = "별명을 입력해주세요")
    @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z]{2,30}$", message = "숫자 또는 특수문자를 제외한 2자이상 입력해주세요")
    private String nickname;

    @NotBlank
    private int age;

    private String gender;

    private Authority authority = Authority.ROLE_USER;

    private boolean fromOauth = false;

    private String storeName;
    private String ownerName;

    private String storeRoadAddress;
    private String storeDetailAddress;

    @Pattern(regexp = "^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$", message = "휴대폰번호를 확인해 주세요")
    private String phoneNumber;

    @NotBlank
    private Address address;

    @Builder
    public SignupDto(String username, String password, String email, String nickname, int age, String gender){
        this.username = username;
        this.password = password;
        this.email = email;
        this.nickname = nickname;
        this.age = age;
        this.gender = gender;
    }

    public Member toMember(PasswordEncoder passwordEncoder) {
        //log.info("비밀번호 = {}", passwordEncoder.encode(password));

        return Member.builder()
                .email(email)
                .password(passwordEncoder.encode(password))
                .nickname(nickname)
                .gender(gender)
                .age(age)
                .authority(Authority.ROLE_USER)
                .build();
    }

    public Store toStore(Member member, Address address) {
        return Store.builder()
                .name(storeName)
                .ownerName(ownerName)
                .address(address)
                .phoneNumber(phoneNumber)
                .member(member)
                .build();
    }

    public Address toAddress(String roadAddr, String detailAddr) {
        return new Address(roadAddr, detailAddr);
    }

    public UsernamePasswordAuthenticationToken toAuthentication() {
        return new UsernamePasswordAuthenticationToken(email, password);
    }

}

  fig 4-2의 형식으로 프론트 단에서 요청을 보내면 위와 같은 dto 객체로 요청을 받는다. 이것을 엔티티 객체로 바꿔주는 메소드가 존재한다.

Controller

    @PostMapping("/signup-owner")
    public ResponseEntity<?> signupOwner(@RequestBody SignupDto signupDto, Errors errors) {
        //validation check
        if (errors.hasErrors()) {
            return response.invalidFields(Helper.refineErrors(errors));
        }
        return memberService.signupOwner(signupDto);
    }

  fig 4-3에서 활성화된 '회원가입' 버튼을 클릭하면 다음 컨트롤러와 매핑된다. 바로 관련된 서비스 메소드를 실행한다.

Service

    public ResponseEntity<?> signupOwner(SignupDto signUp) {
        /* 해당 이메일 계정이 이미 존재하는지 확인*/
        if (memberRepository.existsByEmail(signUp.getEmail())) {
            return response.fail("이미 회원가입된 이메일입니다.", HttpStatus.BAD_REQUEST);
        }

        /* SignupDto를 통해 추가할 Member 객체 생성 및 저장 */
        Member member = signUp.toMember(passwordEncoder);
        member.changeRole(Authority.ROLE_OWNER); // 일반 사용자와 구분하기 위해 authority 변경
        memberRepository.save(member);

        /* SignupDto를 통해 추가할 Store 객체 생성 및 저장 */
        Store store = signUp.toStore(member, signUp.toAddress(signUp.getStoreRoadAddress(), signUp.getStoreDetailAddress()));
        storeRepository.save(store);

        return response.success("회원가입에 성공했습니다.");
    }

  spring data jpa를 쓰니 굉장히 간단하게 코드가 완성되었다. 우선 member 테이블에서 입력한 이메일과 같은 것이 있는지 확인한다. 동일한 이메일이 없는 것을 확인하면 dto의 메소드를 이용해 member 객체를 생성한다.  이때 passwordEncoder를 넘겨주는 이유는 db에 저장되는 패스워드가 그대로 저장되면 보안에 취약하기 때문이다. 그리고 회원 종류를 구분해주기 위해 인스턴수 변수 authority를 'ROLE_OWNER"로 바꿔주고  memberRepositoert.save를 이용해 DB에 member 정보를 저장한다. store 객체도 비슷하게 만든 뒤에 저장하면 된다.

  성공적으로 해당 메소드가 수행되었다면 DB에서 추가된 내용을 확인할 수 있다.

fig 4-5. 저장결과

 

이메일 인증

  그런데 fig 4-3을 보면 알겠지만 메일 인증 입력창이 있는 것을 확인할 수 있다. '다음' 버튼이 활성화 되기 위해서는 특정 코드를 입력한 이메일로 보낸 뒤에 해당 코드와 동일한 코드를 입력하여 같은지 비교하는 과정도 필요한 것이다.

Dependency

dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-mail'
}

  우선 다음과 같은 dependency를 추가해줘야 한다.

Properties

  그리고 .properties 파일에 다음의 내용을 추가해줬다.

#이메일 인증 설정
mail.smtp.auth=true
mail.smtp.starttls.required=true
mail.smtp.starttls.enable=true
mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
mail.smtp.socketFactory.fallback=false
mail.smtp.port=465
mail.smtp.socketFactory.port=465

#구글 계정
AdminMail.id = 발신_이메일_주소
AdminMail.password = 앱비밀번호(계정 비밀번호 x)

  여러 곳을 찾아보며 설정을 바꿔봤는데 계속 해당 계정으로 로그인을 못하는 오류가 발생했었다. 대부분의 글에서 구글 계정 설정에서 '보안 수준이 낮은 앱의 액세스'를 허용해줘야 한다고 했는데 알고보니 최근 몇 개월 전부터는 이 방법이 아닌 '앱 비밀번호'를 사용해야 한다고 했다.

fig 5-1. 앱 비밀번호

  그러므로 앞으로는 '로그인 -> 계정 -> 보안 -> Google에 로그인 -> 앱 비밀번호'에 들어가서 fig 5-1같은 페이지에서 앱 비밀번호를 생성해준 뒤 그것을 AdminMail.password 값으로 대신 넣어주면 된다.

Config

@Configuration
@PropertySource("classpath:application.properties")
public class EmailConfig {

    @Value("${mail.smtp.port}")
    private int port;
    @Value("${mail.smtp.socketFactory.port}")
    private int socketPort;
    @Value("${mail.smtp.auth}")
    private boolean auth;
    @Value("${mail.smtp.starttls.enable}")
    private boolean starttls;
    @Value("${mail.smtp.starttls.required}")
    private boolean startlls_required;
    @Value("${mail.smtp.socketFactory.fallback}")
    private boolean fallback;
    @Value("${AdminMail.id}")
    private String id;
    @Value("${AdminMail.password}")
    private String password;

    @Bean
    public JavaMailSender javaMailService() {
        JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
        javaMailSender.setHost("smtp.gmail.com");
        javaMailSender.setUsername(id);
        javaMailSender.setPassword(password);
        javaMailSender.setPort(port);
        javaMailSender.setJavaMailProperties(getMailProperties());
        javaMailSender.setDefaultEncoding("UTF-8");
        return javaMailSender;
    }
    private Properties getMailProperties()
    {
        Properties pt = new Properties();
        pt.put("mail.smtp.socketFactory.port", socketPort);
        pt.put("mail.smtp.auth", auth);
        pt.put("mail.smtp.starttls.enable", starttls);
        pt.put("mail.smtp.starttls.required", startlls_required);
        pt.put("mail.smtp.socketFactory.fallback",fallback);
        pt.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        return pt;
    }
}

  그리고 .properties에 추가해준 값을 이용해 메일 서비스와 프로퍼티들을 설정해준다.

Controller

    @PostMapping("/email")
    public ResponseEntity<?> sendEmail(@RequestBody Map<String, String> param) {
        String email = param.get("email");
        return memberService.sendEmail(email);
    }

    @PostMapping("email-auth")
    public ResponseEntity<?> authEmail(@RequestBody Map<String, String> param) {
        String code = param.get("code");
        return memberService.authEmail(code);
    }

  컨트롤러는 입력한 이메일(ex. {"email":"abc123@abc.com"} )을 받아서 해당 메일 주소로 인증 코드를 보내는 sendEmail과 입력한 인증코드(ex. {"code":"ABC1234"} )가 알맞은 코드인지 확인하는 authEmail이 있다. 각각 요청 body의 json을 파싱하여 이메일이나 코드 값을 인자로 하여 서비스로 넘긴다.

Service

    public ResponseEntity<?> sendEmail(String email) {
        String code = SecurityUtil.generateCode(); // 가입코드 생성

        /* 메일 제목과 내용 구성 */
        String subject = "Ari 인증을 위한 인증번호입니다.";
        String content="";
        content+= "<div style='margin:100px;'>";
        content+= "<h1> 안녕하세요 Ari입니다. </h1>";
        content+= "<br>";
        content+= "<p>아래 코드를 인증 창으로 돌아가 입력해주세요<p>";
        content+= "<br>";
        content+= "<p>감사합니다!<p>";
        content+= "<br>";
        content+= "<div align='center' style='border:1px solid black; font-family:verdana';>";
        content+= "<h3 style='color:blue;'>인증 코드입니다.</h3>";
        content+= "<div style='font-size:130%'>";
        content+= "CODE : <strong>";
        content+= code+"</strong><div><br/> ";
        content+= "</div>";

        /* JavaMailSender를 이용해 인증코드 전송 */
        try {
            MimeMessage mimeMessage = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "utf-8");
            helper.setTo(email);
            helper.setFrom("aritest0222@gmail.com");
            helper.setSubject(subject);
            helper.setText(content, true);
            javaMailSender.send(mimeMessage);
        } catch (MessagingException e) {
            e.printStackTrace();
        }

        log.info("인증코드: {}", code); //코드 확인용. 나중에 삭제해야함

        /* redis에 인증코드 5분간 보관 */
        redisTemplate.opsForValue()
                .set(code, email, 5*60000, TimeUnit.MILLISECONDS);

        return response.success();
    }

  우선 인증코드를 전송하는 메일부터 살펴보자. generateCode를 통해 랜덤한 코드 값을 만든 뒤에 해당 값을 content에 담아줬다. 그리고 JavaMailSender를 이용해 수신자, 발신자, 제목, 내용 등을 설정해 준 뒤 메일을 전송했다. 이때 생성된 코드는 redis를 이용해 5분간 저장된다. 이 말은 5분 안에 인증코드를 입력해야 한다는 뜻이다. 

  밑은 랜덤한 인증코드를 만들었던 'SecurityUtil.generateCode()'이다.

    public static String generateCode() {
        Random random = new Random();
        String code = "";

        for(int i = 0;i<3;i++){
            int index = random.nextInt(25)+65;
            code+=(char)index;
        }
        int numIndex = random.nextInt(9999)+1000;
        code +=numIndex;

        return code;
    }

  성공적으로 sendEmail 서비스가 수행되면 다음과 같은 메일을 수신할 수 있다.

fig 5-2. 인증코드 수신

  메일 인증은 나중에 비밀번호 찾기를 구현할 때에도 유용할 것 같다.


참고 자료

 

로그인 구현 - 쿠키와 세션

로그인 구현 - 쿠키와 세션

velog.io

 

여기 저번에 왔던 것 같은데?

2021.04.01 신입 Java 백엔드 개발자

wildeveloperetrain.tistory.com

 

[Spring Boot] 이메일 인증 회원가입하기 구현

현재 진행하고 있는 프로젝트에서 사용하고 있는 이메일을 이용해 회원가입을 진행할 수 있도록 구현했습니다. 네이버, 구글, 다음 등의 가입되어있는 이메일을 이용하여 해당 이메일로 인증번

javaju.tistory.com

 

댓글