JPA 회원가입 구현

Dave.kim·2023년 7월 23일

프로젝트

목록 보기
2/8
post-thumbnail

jpa로 회원가입을 구현해보았다.

  1. 회원가입에 필요한 Request, Response, Entity, Repository, Service, Controller 구현
  2. 예외처리 핸들러 작성

프로젝트 구조

  • 프로젝트의 기반은 이렇다. 하지만 아직 비어있는 클래스가 대다수다.
  • global/config/error 패키지, global/config/BaseEntity 클래스를 사용 중이다.
  • domain/web/seller 도메인을 사용 중이다.

dto 패키지

  • DTO(Data Transfer Object)는 계층간 데이터 교환을 위한 자바 빈즈다. 아래는 서비스에서 회원 가입을 처리하기 위한 요청(request)과 응답(response) DTO 클래스다.

PostSignUpSellerReq.java

package com.umc.jaetteoli.domain.web.seller.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostSignUpSellerReq {
    private String uid; // 유저아이디
    private String name; // 유저 이름
    private String birthday; // 유저 생일  yyyy.MM.dd
    private String phone; // 유저 전화번호 010xxxxxxxx
    private String password; // 유저 비밀번호
    private String email; //이메일
    private int serviceCheck; // 서비스 이용동의
    private int personalCheck; // 개인정보 이용동의
    private int smsCheck; // sms 이용동의
    private int emailCheck; // 이메일 이용동의
    private int callCheck; // 전하 수신 동의
}

PostSignUpSellerRes.java

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PostSignUpSellerRes {
    private String uid;
    private String name;
    private String birthday;
    private String phone;
    private String email;
    private String completeDate; // 처리날짜
    private int smsCheck;
    private int emailCheck;
    private int callCheck;
}

어노테이션

  1. @Data Lombok 라이브러리의 어노테이션이다. @Data 어노테이션이 선언된 클래스에 대해 getter와 setter, equals(), hashCode(), toString() 메서드들을 자동으로 생성해준다.

ex.

@Data
public class Example {
    private int id;
    private String name;
}

위와 같이 클래스를 선언하면, 아래와 같이 getter와 setter를 사용할 수 있다.

Example example = new Example();
example.setId(1); // setter
example.setName("jskim2x"); // setter
System.out.println(example.getId()); // getter
System.out.println(example.getName()); // getter

  1. @AllArgsConstructor 모든 필드 값을 파라미터로 받는 생성자를 생성한다.

ex.

@AllArgsConstructor
public class Example {
    private int id;
    private String name;
}

위의 클래스는 아래와 같이 사용할 수 있다.

Example example = new Example(1, "jskim2x");

  1. @NoArgsConstructor: 파라미터가 없는 기본 생성자를 생성한다.

ex.

@NoArgsConstructor
public class Example {
    private int id;
    private String name;
}

위의 클래스는 아래와 같이 사용할 수 있다.

Example example = new Example();

  1. @Builder: 빌더 패턴 클래스를 생성하며, 생성자 상단에 선언하면 생성자에 포함된 필드만 빌더에 포함시킨다.

ex.

@Builder
public class Example {
    private int id;
    private String name;
}

위의 클래스는 아래와 같이 사용할 수 있다.

Example example = Example.builder()
		.id(1)
        .name("jskim2x")
        .build();

참고 블로그


entity 패키지

BaseEntity.java

package com.umc.jaetteoli.global.config;

import lombok.Getter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.time.LocalDateTime;

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {
    @CreationTimestamp
    @Column(updatable = false)
    protected LocalDateTime createdAt;

    @UpdateTimestamp
    protected LocalDateTime updatedAt;

    @Enumerated(value = EnumType.STRING)
    protected Status status= Status.valueOf(Status.ACTIVE.toString());


    public enum Status {
        ACTIVE,
        DELETE
    }

    public void updateStatus(Status status){
        this.status=status;
    }
}
  • 앞으로 모든 엔티티 클래스들은 이 BaseEntity를 상속받도록 할 것이다.
  • 해당 내용에는 createdAt(생성일), updatedAt(수정일), status(상태값)가 들어간다.

Seller.java

package com.umc.jaetteoli.domain.web.seller.entity;

import lombok.*;
import com.umc.jaetteoli.global.config.BaseEntity;
import org.hibernate.annotations.ColumnDefault;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(name = "merchandisers")
public class Seller extends BaseEntity implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long sellerIdx;

    @Column(name = "name", nullable = false, length = 75)
    private String name;

    @Column(name = "birthday", nullable = false, length = 45)
    private String birthday;

    @Column(name = "phone", nullable = false, length = 45)
    private String phone;

    @Column(name = "uid", nullable = false, length = 45)
    private String uid;

    @Column(name = "password", nullable = false, length = 100)
    private String password;

    @Column(name = "email", nullable = false, length = 100)
    private String email;

    @Column(name = "first_login", nullable = false)
    @ColumnDefault("1")
    private int firstLogin;

    @Column(name = "menu_register", nullable = false)
    @ColumnDefault("0")
    private int menuRegister;

    @Column(name = "service_check", nullable = false)
    @ColumnDefault("0")
    private int serviceCheck;

    @Column(name = "personal_check", nullable = false)
    @ColumnDefault("0")
    private int personalCheck;

    @Column(name = "sms_check", nullable = false)
    @ColumnDefault("0")
    private int smsCheck;

    @Column(name = "email_check", nullable = false)
    @ColumnDefault("0")
    private int emailCheck;

    @Column(name = "call_check", nullable = false)
    @ColumnDefault("0")
    private int callCheck;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }
    @Override
    public String getPassword() {
        return null;
    }
    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

repository 패키지

SellerRepository.java

package com.umc.jaetteoli.domain.web.seller.repository;

import com.umc.jaetteoli.domain.web.seller.entity.Seller;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface SellerRepository extends JpaRepository<Seller,Long> {
    Optional<Seller> findByUid(String uid);
}

service 패키지

SellerService.java

package com.umc.jaetteoli.domain.web.seller.service;

import com.umc.jaetteoli.domain.web.seller.dto.PostSignUpSellerReq;
import com.umc.jaetteoli.domain.web.seller.dto.PostSignUpSellerRes;
import com.umc.jaetteoli.domain.web.seller.entity.Seller;
import com.umc.jaetteoli.domain.web.seller.repository.SellerRepository;
import com.umc.jaetteoli.global.config.error.exception.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import static com.umc.jaetteoli.global.config.error.ErrorCode.*;
import static com.umc.jaetteoli.global.util.Regex.*;

@Service
@RequiredArgsConstructor
public class SellerService {

    private final SellerRepository sellerRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional(rollbackFor = BaseException.class)
    public PostSignUpSellerRes signUp(PostSignUpSellerReq postSignUpSellerReq) throws BaseException {
        // 1. Request값 검사 (빈값여부,정규식 일치 검사)
        if(checkIsEmptySignUpBySeller(postSignUpSellerReq)){ // 빈값여부 check
            throw new BaseException(BAD_REQUEST);
        }
        if(!isRegexUid(postSignUpSellerReq.getUid())){ // 아이디 정규 표현식 예외
            throw new BaseException(INVALID_UID_FORMAT);
        }
        if(!isRegexPassword(postSignUpSellerReq.getPassword())){ // 비밀번호 정규 표현식 예외
            throw new BaseException(INVALID_PW_FORMAT);
        }
        if(!isRegexBirth(postSignUpSellerReq.getBirthday())){ // 생년월일 정규 표현식 예외
            throw new BaseException(INVALID_BIRTH_FORMAT);
        }
        if(!isRegexPhone(postSignUpSellerReq.getPhone())){ // 핸드폰번호 정규 표현식 예외
            throw new BaseException(INVALID_PHONE_NUM_FORMAT);
        }

        // 2. 중복 아이디 검사 및 비밀번호 암호화
        if(sellerRepository.findByUid(postSignUpSellerReq.getUid()).isPresent()){ // 중복 아이디 검사
            throw new BaseException(ID_ALREADY_EXISTS);
        }
        try{ // 비밀번호 암호화 -> 사용자 요청 값 중 비밀번호 최신화
            String encryptPassword = passwordEncoder.encode(postSignUpSellerReq.getPassword());
            postSignUpSellerReq.setPassword(encryptPassword);
        }catch (Exception e){
            throw new BaseException(PASSWORD_ENCRYPTION_FAILURE); // 비밀번호 암호화 실패 시
        }


        try{
            Seller newSeller = Seller.builder()
                    .name(postSignUpSellerReq.getName())
                    .birthday(postSignUpSellerReq.getBirthday())
                    .phone(postSignUpSellerReq.getPhone())
                    .uid(postSignUpSellerReq.getUid())
                    .password(postSignUpSellerReq.getPassword())
                    .email(postSignUpSellerReq.getEmail())
                    .firstLogin(1)
                    .serviceCheck(postSignUpSellerReq.getServiceCheck())
                    .personalCheck(postSignUpSellerReq.getPersonalCheck())
                    .smsCheck(postSignUpSellerReq.getSmsCheck())
                    .emailCheck(postSignUpSellerReq.getEmailCheck())
                    .callCheck(postSignUpSellerReq.getCallCheck())
                    .build();
            // 3. 유저 insert
            newSeller = sellerRepository.save(newSeller);
            // 4. 방금 insert한 유저 반환
            PostSignUpSellerRes checkSeller = PostSignUpSellerRes.builder()
                    .uid(newSeller.getUid())
                    .name(newSeller.getName())
                    .birthday(newSeller.getBirthday())
                    .phone(newSeller.getPhone())
                    .email(newSeller.getEmail())
                    .completeDate(convertTimestampToString(newSeller.getCreatedAt()))
                    .smsCheck(newSeller.getSmsCheck())
                    .emailCheck(newSeller.getEmailCheck())
                    .callCheck(newSeller.getCallCheck())
                    .build();

            return checkSeller;
        }catch (Exception e){
            System.out.println(e);
            throw new BaseException(DATABASE_ERROR);
        }
    }

    /**
     * 입력값 빈값 여부 판별 메소드
     * 이름, 생년월일, 휴대폰 번호, 아이디, 비밀번호 빈값 체크
     */
    public boolean checkIsEmptySignUpBySeller(PostSignUpSellerReq postSignUpSellerReq){
        return postSignUpSellerReq.getUid().length()==0 || postSignUpSellerReq.getPassword().length()==0 || postSignUpSellerReq.getName().length()==0
                || postSignUpSellerReq.getBirthday().length()==0 || postSignUpSellerReq.getEmail().length()==0 || postSignUpSellerReq.getPhone().length()==0;
    }

    public String convertTimestampToString(LocalDateTime localDateTime) {
        DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy.MM.dd");
        return dateFormat.format(localDateTime);
    }
}
  • 첫번째로, 들어온 요청 값들에 대한 Validation 검사를 수행하는 부분인데, 이를 통해 내가 커스텀해서 만든 BaseException의 형태로 예외를 throw하게 된다.

    • throw된 예외는 GlobalExceptionHandler 이 클래스에서 처리하게 된다.
  • 두번째로, 중복된 아이디를 검사하게 되는데, 여기서 SellerRepositoryfindByUid함수가 쓰인다.

    • 아이디를 기준으로 검사해야하기 때문에 함수명을 저렇게 지었다.
  • 비밀번호 암호화로는 PasswordEncoder를 사용했는데, 원래는 BCrypt, SHA256과 같은 해시함수들을 이용해 따로 커스텀하려고 했지만, 이미 스프링부트에서 너무 좋은 클래스를 지원해줘서 이걸 쓰기로 했다.

  • 세번째로, Database에 저장하는 부분인데, JpaRepository가 제공해주는 save 함수로 간단히 구현했고, 이와 동시에 newSeller 변수에 해당 행에 들어간 데이터들을 모두 저장해준다.

  • 네번째로, newSeller 변수의 값들을 PostSignUpSellerRes에 넣어서 반환해준다.

  • checkIsEmptySignUpBySeller 이 함수는 요청 값들 중에서 Empty값이 존재하는지를 검사해주는 메소드다.

  • convertTimestampToString 이 함수는 createdAt의 날짜 형식을 yyyy.MM.dd 형식으로 변환해주는 메소드다.


SellerController.java

package com.umc.jaetteoli.domain.web.seller;

import com.umc.jaetteoli.domain.web.seller.dto.PostSignUpSellerReq;
import com.umc.jaetteoli.domain.web.seller.dto.PostSignUpSellerRes;
import com.umc.jaetteoli.domain.web.seller.service.SellerService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.umc.jaetteoli.global.config.error.exception.BaseException;
import com.umc.jaetteoli.global.config.error.BaseResponse;

import static com.umc.jaetteoli.global.config.error.BaseResponseStatus.BAD_REQUEST;
import static com.umc.jaetteoli.global.config.error.BaseResponseStatus.SUCCESS;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/web")
public class SellerController {
    // 생성자 주입
    private final SellerService sellerService;

    @GetMapping("/test")
    public ResponseEntity<BaseResponse> userTest(){
        try{
            return ResponseEntity.ok(new BaseResponse<>(SUCCESS));
        } catch(BaseException exception){
            return ResponseEntity.status(HttpStatus.CONFLICT).body(new BaseResponse<>(BAD_REQUEST));
        }
    }

    @PostMapping("/jat/sellers")
    public ResponseEntity<BaseResponse<PostSignUpSellerRes>> signUp(@RequestBody PostSignUpSellerReq postSignUpSellerReq) {
        PostSignUpSellerRes postSignUpSellerRes = sellerService.signUp(postSignUpSellerReq);
        return ResponseEntity.ok(new BaseResponse<>(postSignUpSellerRes));
    }
}

기타 클래스

GlobalExceptionHandler

package com.umc.jaetteoli.global.config.error;

import com.umc.jaetteoli.global.config.error.exception.BaseException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import javax.servlet.http.HttpServletRequest;

import java.net.BindException;

import static com.umc.jaetteoli.global.config.error.ErrorCode.*;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    ...
    ...
    ...

    // Application Exception
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<BaseResponse<?>> handleBaseException(BaseException e) {
        // 예외에서 메시지와 코드만 추출
        String errorMessage = e.getMessage();
        int errorCode = e.getCode();

        // BaseResponse 생성
        BaseResponse<?> response = new BaseResponse<>(errorMessage, errorCode);

        return ResponseEntity
                .status(e.getCode())
                .body(response);
    }


}
  • 여기가 애플리케이션 상에서 발생하는 모든 BaseException 에러들을 핸들링해주는 부분이다.

BaseResponse.java

package com.umc.jaetteoli.global.config.error;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.AllArgsConstructor;
import lombok.Getter;

import static com.umc.jaetteoli.global.config.error.BaseResponseStatus.SUCCESS;

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
public class BaseResponse<T> {//BaseResponse 객체를 사용할때 성공, 실패 경우
    @JsonProperty("isSuccess")
    private final Boolean isSuccess;
    private final String message;
    private final int code;
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T result;

    // 성공
    public BaseResponse(T result) {
        this.isSuccess = SUCCESS.isSuccess();
        this.message = SUCCESS.getMessage();
        this.code = SUCCESS.getCode();
        this.result = result;
    }

    public BaseResponse(ErrorCode errorCode) {
        this.isSuccess = false;
        this.message = errorCode.getErrorMessage();
        this.code= errorCode.getCode();
    }

    public BaseResponse(String message, int code) {
        this.isSuccess = false;
        this.message = message;
        this.code = code;
    }
}

ErrorCode.java

package com.umc.jaetteoli.global.config.error;

import lombok.Getter;
import org.springframework.http.HttpStatus;

import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.METHOD_NOT_ALLOWED;

@Getter
public enum ErrorCode {
    SUCCESS(HttpStatus.OK,200,  "요청에 성공하였습니다."),
    BAD_REQUEST( HttpStatus.BAD_REQUEST,400,  "입력값을 확인해주세요."),
    FORBIDDEN(HttpStatus.FORBIDDEN,  403,"권한이 없습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, 404,"대상을 찾을 수 없습니다."),
    // [Seller] 회원가입
    INVALID_UID_FORMAT(HttpStatus.BAD_REQUEST,400,"아이디 정규 표현식 예외입니다."),
    INVALID_PW_FORMAT(HttpStatus.BAD_REQUEST,400,"비밀번호 정규 표현식 예외입니다."),
    INVALID_BIRTH_FORMAT(HttpStatus.BAD_REQUEST,400,"생년월일 정규 표현식 예외입니다."),
    INVALID_PHONE_NUM_FORMAT(HttpStatus.BAD_REQUEST,400,"핸드폰번호 정규 표현식 예외입니다."),
    ID_ALREADY_EXISTS(HttpStatus.CONFLICT,409,"중복된 아이디 입니다."),
    PASSWORD_ENCRYPTION_FAILURE(INTERNAL_SERVER_ERROR,500,"비밀번호 암호화에 실패했습니다."),

    // Database 예외
    DATABASE_ERROR(INTERNAL_SERVER_ERROR,500,"데이터베이스 오류입니다.");

    private final HttpStatus httpStatus;
    private final int code;
    private final String errorMessage;

    ErrorCode( HttpStatus httpStatus, int code, String errorMessage) {

        this.httpStatus = httpStatus;
        this.code = code;
        this.errorMessage = errorMessage;
    }
}

회원가입 로직

		// 1. Request값 검사 (빈값여부,정규식 일치 검사)
        if(checkIsEmptySignUpBySeller(postSignUpSellerReq)){ // 빈값여부 check
            throw new BaseException(BAD_REQUEST);
        }
        if(!isRegexUid(postSignUpSellerReq.getUid())){ // 아이디 정규 표현식 예외
            throw new BaseException(INVALID_UID_FORMAT);
        }
        if(!isRegexPassword(postSignUpSellerReq.getPassword())){ // 비밀번호 정규 표현식 예외
            throw new BaseException(INVALID_PW_FORMAT);
        }
        if(!isRegexBirth(postSignUpSellerReq.getBirthday())){ // 생년월일 정규 표현식 예외
            throw new BaseException(INVALID_BIRTH_FORMAT);
        }
        if(!isRegexPhone(postSignUpSellerReq.getPhone())){ // 핸드폰번호 정규 표현식 예외
            throw new BaseException(INVALID_PHONE_NUM_FORMAT);
        }
  • 서비스 로직 내에서 검증: SellerService의 signUp 메서드에서는 입력된 요청 데이터(PostSignUpSellerReq)가 적절한지 검사하는 로직.
  • 이때 데이터에 문제가 있으면 BaseException을 발생시킨다.
  • 입력 필드가 비어있는지 검사한다. 빈 필드가 있으면 BAD_REQUEST 예외를 발생시킨다.
  • 각 필드의 형식이 정규 표현식에 맞는지 검사합니다. 만약 형식이 맞지 않으면 각각의 형식에 맞는 예외를 발생시킵니다(INVALID_UID_FORMAT, INVALID_PW_FORMAT, INVALID_BIRTH_FORMAT, INVALID_PHONE_NUM_FORMAT).
  • sellerRepository.findByUid 메서드를 사용하여 동일한 ID의 사용자가 있는지 검사합니다. 동일한 ID가 있다면 ID_ALREADY_EXISTS 예외를 발생시킵니다.
  • 패스워드 암호화 과정에서 예외가 발생하면 PASSWORD_ENCRYPTION_FAILURE 예외를 발생시킵니다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    ...
    ...
    ...

    // Application Exception
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<BaseResponse<?>> handleBaseException(BaseException e) {
        // 예외에서 메시지와 코드만 추출
        String errorMessage = e.getMessage();
        int errorCode = e.getCode();

        // BaseResponse 생성
        BaseResponse<?> response = new BaseResponse<>(errorMessage, errorCode);

        return ResponseEntity
                .status(e.getCode())
                .body(response);
    }


}
  • 예외 처리기를 사용한 예외 처리: GlobalExceptionHandler 클래스는 @RestControllerAdvice 어노테이션이 붙어 있어서 컨트롤러에서 발생하는 예외를 처리한다.

  • handleBaseException 메서드에서 BaseException을 처리하며, 해당 예외의 메시지와 코드를 추출하여 BaseResponse에 넣어 응답하게 된다.

  • 이런 방식으로 예외를 처리한 이유는 API 호출에 대한 응답이 일관되게 유지되게 하고, 가독성과 유지 보수성을 향상시키기 위함이다.

  • 추가적으로 이야기하면, 서비스 메서드에서 예외가 발생하면 해당 예외는 호출한 컨트롤러로 전파되고, 컨트롤러에서는 이 예외를 처리하지 않아서 다시 상위로 전파되고, 결국 GlobalExceptionHandler에서 이 예외를 잡아 처리하게 된다.

API 테스트

  • 해당 URL로 잘 들어오는 것을 확인할 수 있다.
  • 생년월일 정규식이 yyyy.mm.dd 이 형식으로 요청을 넣어줘야 하기 때문에 해당 예외를 뱉어냈다.
  • 핸드폰 번호 정규식은 010xxxxxxxx 이렇게 다 붙여서 요청을 보내줘야 하기 때문에 해당 예외를 뱉어냈다.

1개의 댓글

comment-user-thumbnail
2023년 7월 23일

잘 읽었습니다. 좋은 정보 감사드립니다.

답글 달기