본문으로 바로가기

목차

  • 개발 환경
  • 회원가입 및 로그인 기능 구현
  • 리팩토링
    • 빌더패턴 적용
    • API 공통 Response 포맷 구현
    • 전역 예외처리
    • Swagger 적용
  • 회고

 

개발 환경

  • Java: 11
  • Spring Boot: 2.7.14
  • 빌드 관리 도구: Gradle
  • DBMS: MySQL 8.0.28
  • DB Access: JPA
  • View Templete Engine: thymeleaf
  • 개발 툴: IntelliJ IDEA
  • 라이브러리: web, jpa,  thymeleaf, validation, lombok, mysql

 

회원가입 및 로그인 기능 구현 

원래는 프론트 부분 없이 API만 개발해서 백엔드 기능들만 구현하려고 했었다. 

하지만 회원가입과 로그인에서는 폼을 직접 구현하면서 타임리프도 적용해보면 좋을 것 같아 간단하게 뷰도 함께 만들게 되었다.

 

MemberService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    /**
     * 회원 가입
     */
    @Transactional
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }
    
    
    /**
     * 로그인
     */
    public Long login(LoginRequestDto member) {
        Member loginMember = memberRepository.findByMemberId(member.getMemberId())
                .orElseThrow(() -> new IllegalStateException(ErrorMessage.MEMBER_ID_NOT_EXIST));

        if(!loginMember.getPassword().equals(member.getPassword())) {
            throw new IllegalStateException(ErrorMessage.WRONG_PASSWORD);
        }

        return loginMember.getId();
    }
    
    private void validateDuplicateMember(Member member) {
        if(memberRepository.findByMemberId(member.getMemberId()).isPresent()) {
            throw new IllegalStateException(ErrorMessage.MEMBER_ID_ALREADY_EXIST);
        }
        
        if(memberRepository.findByMemberId(member.getNickname()).isPresent()) {
            throw new IllegalStateException(ErrorMessage.NICKNAME_ALREADY_EXIST);
        }
        
                
        if(memberRepository.findByEmail(member.getEmail()).isPresent()) {
            throw new IllegalStateException(ErrorMessage.EMAIL_ALREADY_EXIST);
        }
    }
}
  • 회원 아이디 또는 닉네임, 이메일이 중복되면 회원가입이 실패되기 때문에 예외를 발생시켰다.
  • 로그인 시에도 회원 아이디와 비밀번호가 틀릴 경우 예외를 발생시켰다.
  • [고려] API 예외 처리
    • 단순히 뷰를 반환하는 컨트롤러라면 HTML 화면 오류 페이지를 보여주면 되지만, API의 경우 각 컨트롤러나 예외마다 다른 응답 값을 주어야한다.
    • 현재는 컨트롤러에서 발생하는 Exception을 처리하는 곳이 없기 때문에 예외 핸들러를 따로 만들 것이다.

 

Member

@Entity
@Table
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_pk")
    private Long id;

    @Column(unique = true, nullable = false)
    private String memberId;

    @Column(unique = true, nullable = false)
    private String nickname;

    @Column(nullable = false)
    private String password;

    @Column(unique = true)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Authority authority; // 권한

    private LocalDateTime dormantConversionDate;
}
  • 원래 권한은 멤버 테이블과 1:1 관계로 매핑시키려고 했었다.
  • 하지만 이 둘이 관계를 맺기보다는 권한이 멤버의 속성에 포함된다고 생각되어 Enum으로 넣어주었다.

 

MemberController

@Slf4j
@Controller
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members/new")
    public String createForm(Model model) {
        model.addAttribute("memberForm", new MemberForm());
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    public String create(@Valid MemberForm form, BindingResult result) {
        if(result.hasErrors()) {
            log.info("errors={}", result);
            return "members/createMemberForm";
        }

        Member member = new Member();
        member.setMemberId(form.getMemberId());
        member.setNickname(form.getNickname());
        member.setPassword(form.getPassword());
        member.setEmail(form.getEmail());

        memberService.join(member);

        return "redirect:/members/login";
    }
    
    @GetMapping("/members/login")
    public String loginForm(Model model) {
        model.addAttribute("loginForm", new LoginForm());
        return "members/loginForm";
    }
    
    @PostMapping("/members/login")
    public String login(@Valid LoginForm form, BindingResult result) {
        if(result.hasErrors()) {
            return "members/loginForm";
        }

        memberService.login(form.getLoginId(), form.getPassword());
        log.info("[로그인 성공] - /members/login");
        return "redirect:/";
    }

}
  • 회원가입을 위한 MemberForm과 LoginForm Dto를 따로 생성해주었다.
    • MemberApiController에서도 SignUpRequestDto와 LoginRequestDto를 따로 만들어두었다.
  • 관심사의 분리 - DTOEntity
    • DTO(Data Transfer Object)의 핵심 관심사는 데이터의 전달이며, Entity는 핵심 비즈니스 로직을 담은 비즈니스 도메인 영역이다. 
    • 회원가입과 로그인시에 필요한 데이터(SignUpRequestDto)와 핵심 비즈니스 로직에서 필요한 도메인(Member)의 데이터가 다를 수 있다.
    • 따라서 둘의 관심사가 다르기 때문에 분리해야 의도치 않은 데이터 변경 및 노출을 예방할 수 있다.
  • 필드 유효성 검사
    • 검증이 필요한 객체 내부에 세부적인 사항을 정의해두고, 요청이 왔을 때 해당 객체에 대한 검증을 수행한다. 
    • 이때 오류가 있으면 BindingResult에 담기게 되는데, 에러가 있을 경우 다시 해당 폼 뷰를 반환한다.
    • [참고] Bean Validation

 

MemberApiController

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberApiController {

    private final MemberService memberService;

    @PostMapping("/join")
    private ResponseEntity<String> signUp(@RequestBody @Valid SignInRequestDto request, BindingResult bindingResult) {

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            String errorMessage = getErrorMessage(bindingResult);
            return new ResponseEntity<>("fail: " + errorMessage, HttpStatus.BAD_REQUEST);
        }

        Member member = new Member();
        member.setMemberId(request.getMemberId());
        member.setNickname(request.getNickname());
        member.setPassword(request.getPassword());
        member.setEmail(request.getEmail());
        member.setAuthority(Authority.MEMBER); // 권한 설정

        try {
            memberService.join(member);
        } catch (IllegalStateException e) {
            log.info(e.getMessage());
            return new ResponseEntity<>("fail: " + e.getMessage(), HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>("success", HttpStatus.CREATED);
    }

    @PostMapping("/login")
    private ResponseEntity<String> login(@RequestBody @Valid LoginRequestDto request, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            String errorMessage = getErrorMessage(bindingResult);
            return new ResponseEntity<>("fail: " + errorMessage, HttpStatus.BAD_REQUEST);
        }

        try {
            memberService.login(request);
        } catch (IllegalStateException e) {
            log.info(e.getMessage());
            return new ResponseEntity<>("fail: " + e.getMessage(), HttpStatus.BAD_REQUEST);
        }

        return new ResponseEntity<>("success", HttpStatus.OK);
    }

    private String getErrorMessage(BindingResult bindingResult) {
        String message = "";
        for(FieldError error : bindingResult.getFieldErrors()) {
            message += error.getDefaultMessage() + " ";
        }
        return message;
    }
}
  • 지금까지는 회원가입과 로그인 API에서 따로 반환할 Data가 없어 "success" 문자열만 응답 값에 넣어주었다.
  • 또한, 예외가 발생했을 때 잡아서 "fail"이라는 문자열과 에러 메시지만 함께 응답 값에 넣어주었다.
  • 여기서 고민해볼 것
    • 현재 DTO에서 Entity로 변환할 때 Entity를 setter를 이용하여 생성해주고 있다. setter는 의도가 분명하지 않고 변경된 지점을 추적하기 어렵기 때문에 사용하는 것을 지양하는 것이 좋다. 따라서 setter가 아닌 다른 방법으로 엔티티를 생성해야 한다.
    • API 정상 응답 및 에러에 대한 응답 포맷이 다르게 되면 클라이언트는 해당 응답이 성공했는지 실패했는지 바로 알기가 어려울 것 같다는 생각이 들었다. 하지만 정상과 에러에 대한 응답 포맷이 같다면 둘을 구분하기도 쉽고, 서버도 또한 항상 같은 포맷 형태로 반환하면 되기 때문에 편할 것이다.
  • [고려] 엔티티 생성
  • [고려] API 응답 
    • API 정상 응답 및 에러에 대한 공통 Response 포맷도 구현해서 적용할 것이다.

 

리팩토링

1. 빌더 패턴 적용

@Entity
@Table
@Getter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_pk")
    private Long id;
    
    ...

    @Builder(builderMethodName = "createMemberBuilder")
    public Member(String memberId, String nickname, String password, String email) {
        this.memberId = memberId;
        this.nickname = nickname;
        this.password = password;
        this.email = email;
        this.role = Role.MEMBER;
    }
}
  • [참고] Entity 생성 방법 - 생성자, 정적 팩토리 메서드, Builder 패턴 
  • @Setter를 제거하고 생성자에 @Builder를 적용하였다. 
    • @Builder를 클래스 위에 선언하게 될 경우, 모든 멤버 필드에 대해 매개변수를 받는 생성자가 필요하다.
    • 이렇게 되면 객체를 생성할 때 필요하지 않은 데이터들도 노출될 수 있기 때문에 받아야할 데이터들만 파라미터로 받는 것이 좋다.
@Getter
public class SignUpRequestDto {
    @NotBlank(message = "회원 아이디는 필수 값입니다.")
    @Size(max = 10, message = "아이디는 10자이내여야 합니다.")
    @Pattern(regexp="^[a-z|A-Z|0-9]*$", message = "아이디는 영문 또는 숫자로만 구성되어야 합니다.")
    @Schema(description = "회원 아이디", example = "test1")
    private String memberId;

    ...

    @Email(message = "이메일 형식을 맞춰주세요")
    @Schema(description = "이메일", example = "test@test.com")
    private String email;

    public Member toEntity() {
        return Member.createMemberBuilder()
                .memberId(memberId)
                .nickname(nickname)
                .password(password)
                .email(email)
                .build();
    }
}
  • @Data에도 @Setter가 포함되어 있기 때문에 삭제하고, @Getter만 적용해주었다.
public class MemberApiController {

    private final MemberService memberService;

    @PostMapping("/join")
    private ResponseEntity<String> signUp(@RequestBody @Valid SignInRequestDto request, BindingResult bindingResult) {
        ...
        
        try {
            Member member = request.toEntity(); // 엔티티 생성
            memberService.join(member);
        } catch (IllegalStateException e) {
            log.info(e.getMessage());
            return new ResponseEntity<>("fail: " + e.getMessage(), HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>("success", HttpStatus.CREATED);
    }
    
    ...
}
  • 컨트롤러에서는 DTO를 Entity로 변경할 때 Setter를 사용하지 않고 Entity를 생성할 수 있게 된다.

 

2. API 공통 Response 포맷 구현

  • 정상 응답 예시
{
    "status" : "SUCCESS",
    "data" : { 
        "member" : { 
            "nickname": "testname", 
            "email": "test@email2"
        }
    }
    "message" : "회원 조회 성공"
}
  • 에러에 대한 응답 예시
{
    "status": "ERROR",
    "data": {
        "errors": {
            "memberId": "회원 아이디는 필수 값입니다."
        }
    },
    "message": "유효하지 않은 데이터 값입니다."
}
  • 실패(회원가입 및 로그인)
{
    "status": "FAILURE",
    "message": "존재하지 않는 아이디입니다."
}

 

공통 응답 객체

@Getter
@RequiredArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {

    private final ResponseStatus status;
    private T data;
    private String message;
}
public enum ResponseStatus {
    SUCCESS, FAILURE, ERROR;
}
  • 에러로 인한 응답과 회원가입 및 로그인 요청에서의 실패는 다르다고 생각되어 응답 상태를 3가지로 나누었다.

 

ResponseUtils

public class ResponseUtils {

    /**
     * 성공
     */
    public static <T>ApiResponse<T> success (String message) {
        return success(null, message);
    }

    public static <T>ApiResponse<T> success (T data) {
        return success(data, null);
    }

    public static <T>ApiResponse<T> success (T data, String message) {
        return new ApiResponse(ResponseStatus.SUCCESS, data, message);
    }

    /**
     * 실패 - 회원가입, 로그인
     */
    public static <T>ApiResponse<T> failure (T data, String message) {
        return new ApiResponse(ResponseStatus.FAILURE, data, message);
    }

    /**
     * 에러
     */
    public static <T>ApiResponse<T> error (T data, ErrorCode message) {
        return error(data, message.getMessage());
    }

    public static <T>ApiResponse<T> error (T data, String message) {
        return new ApiResponse(ResponseStatus.ERROR, data, message);
    }
}
  • 요청 결과에 대한 상태와 인자에 따라 메서드를 사용한다. 

 

MemberApiController

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberApiController {

    private final MemberService memberService;

    @PostMapping("/join")
    @ResponseStatus(HttpStatus.CREATED)
    private ResponseEntity<String> signUp(@RequestBody @Valid SignInRequestDto request, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return ResponseUtils.error(null, getErrorMessage(bindingResult));
        }

        try {
            Member member = request.toEntity();
            memberService.join(member);
        } catch (IllegalStateException e) {
            return ResponseUtils.failure(null, e.getMessage());
        }
        
        return ResponseUtils.success("회원가입 성공");
    }

    @PostMapping("/login")
    @ResponseStatus(HttpStatus.OK)
    private ResponseEntity<String> login(@RequestBody @Valid LoginRequestDto request, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return ResponseUtils.error(null, getErrorMessage(bindingResult));
        }

        try {
            memberService.login(request);
        } catch (IllegalStateException e) {
            return ResponseUtils.failure(null, e.getMessage());
        }

        return ResponseUtils.success("로그인 성공");
    }

    private String getErrorMessage(BindingResult bindingResult) {
        String message = "";
        for(FieldError error : bindingResult.getFieldErrors()) {
            message += error.getDefaultMessage() + " ";
        }
        return message;
    }
}

 

3. 전역 예외 처리

 

API 요청 시, 정상적으로 처리가 되지 않고 Exception이 발생한 경우 에러를 발생시키지 않고 클라이언트에 에러 응답 데이터를 전송할 것이다.

 

동작 과정

  1. 클라이언트가 API를 호출한다.
  2. Controller에서 해당 API에 대한 처리를 수행한다.
    1. 데이터 처리가 정상적으로 처리된 경우
      • 비즈니스 로직 처리를 완료하고 클라이언트로 성공 응답 데이터를 전송한다.
    2. 데이터가 정상처리 되지 않고, Exception이 발생한 경우
      • GlobalExceptionHandler에서 에러를 캐치하여, 클라이언트로 에러 응답 데이터를 전송한다.
  3. 클라이언트와 API의 통신이 완료된다.
    • 성공 응답과 에러 응답은 기존에 구현한 공통 Response 포맷을 바탕으로 status를 구분하여 전송한다.

 

GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 객체, 파라미터 데이터 값이 유효하지 않은 경우
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ApiResponse<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();

        BindingResult bindingResult = e.getBindingResult();
        for(FieldError er : bindingResult.getFieldErrors()) {
            if(errors.containsKey(er.getField())) {
                errors.put(er.getField(), errors.get(er.getField()) + " " + er.getDefaultMessage());
            }
            else {
                errors.put(er.getField(), er.getDefaultMessage());
            }
        }

        Map<String, Object> data = new HashMap<>();
        data.put("errors", errors);

        log.error("[MethodArgumentNotValidException] " + data);

        return ResponseUtils.error(data, INVALID_TYPE_VALUE);
    }

    /**
     * 커스텀 예외 - 기본
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BaseException.class)
    public ApiResponse<?> handleBaseException(BaseException e) {
        log.error("[BaseException] " + e.getErrorMessage());
        return ResponseUtils.failure(null, e.getErrorMessage());
    }
}
  • 예외 처리
    • Bean Validation으로 검증으로 예외가 발생한 경우에 handleMethodArgumentNotValidException으로 처리한다.
    • CustomException인 BaseException이 발생한 경우에는 handleBaseException에서 처리한다.
  • 전역 에러 핸들러(Global Exception Handler)
    • 컨트롤러 내에서 발생한 예외를 @ControllerAdvice 또는 @RestControllerAdvice로 선언한 클래스에서 도중에 캐치하여 예외를 처리한다. 
    • 이때 @ExceptionHandler로 처리하고 싶은 특정 예외를 지정하여 해당 예외가 발생하면 해당 메서드가 호출된다.
  • 이렇게 전역 에러 핸들러를 이용하여 에러가 발생했을 때, 실제 에러를 발생시키지 않고 클라이언트에게 전달하여 발생한 위치와 종류를 추적할 수 있다.
  • [참고] ExceptionHandler 

 

MemberApiController

@Slf4j
@Tag(name = "Member", description = "회원 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberApiController {

    private final MemberService memberService;

    @Operation(summary = "회원 가입", description = "회원을 등록하는 api")
    @PostMapping("/join")
    @ResponseStatus(HttpStatus.CREATED)
    private ResponseEntity<String> signUp(@RequestBody @Valid SignInRequestDto request) {
        memberService.join(request.toEntity());
        return ResponseUtils.success("회원가입 성공");
    }

    @Operation(summary = "로그인 요청", description = "회원 로그인 요청하는 api")
    @PostMapping("/login")
    @ResponseStatus(HttpStatus.OK)
    private ResponseEntity<String> login(@RequestBody @Valid LoginRequestDto request) {
        memberService.login(request.getMemberId(), request.getPassword());
        return ResponseUtils.success("로그인 성공");
    }
}
  • 기존 ApiController에서 BindingResult를 지우고, 요청 데이터에 대한 예외 처리는 전역 에러 핸들러에서 처리한다.
  • 또한, 비즈니스 로직에서 발생한 예외도 전역 에러 핸들러에서 처리하기 때문에 컨트롤러는 예외에 대한 처리를 하지 않는다.

 

4. Swagger 적용

 

API 문서 생성을 자동화하는데 도움을 준다.

@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .components(new Components())
                .info(apiInfo());
    }

    private Info apiInfo() {
        return new Info()
                .title("PeCommunity Project API Document")
                .version("1.0.0")
                .contact(new Contact().name("jimin").url("https://github.com/PE-Community/study"))
                .description("PeCommunity 프로젝트의 API 명세서입니다.");
    }
}
  • @Tag:API 그룹 설정
    • @Tag(name = "Member", description = "회원 API") public class MemberApiController { }
  • @Operation: API 동작에 대한 명세 작성
    • @Operation(summary = "회원 가입", description = "회원을 등록하는 api") private ApiResponse<?> signIn() { }
    • @Operation(summary = "로그인 요청", description = "회원 로그인 요청하는 api") private ApiResponse<?> login() { }
  • @Parameter: API의 파라미터 설정
    • @Parameter(name = "memberId", description = "회원 아이디 값", example = "testId")
  • @Schema: Model에 대한 정보를 설정
    • @Schema(description = "회원 아이디", example = "test1") private String memberId;

 

적용

 

회고

 지금까지 회원가입과 로그인에 대한 기능을 구현해보고, 리팩토링해보는 시간을 가졌다. API를 처음 작성하다보니 응답 값에 대한 고민을 많이 하게 되었던 것 같다. 그 중에서도 Http 상태 코드를 메시지 바디에 넣는 경우도 있었는데, 응답 헤더에서 상태코드를 반환할 수 있음에도 따로 메시지 바디에 한 번 더 넣어주는 이유를 찾지 못했다. 프론트의 입장에서는 메시지 바디에 넣어주는 것이 더 명확한 것인지 아닌지, 아직은 잘 모르기 때문에 좀 더 찾아보면서 고민해볼 예정이다. 또한 에러 응답에서도 Http 상태 코드만으로는 에러에 대한 구체적인 상세 내용을 보여주기에 부족하다고 한다. 그래서 따로 에러 코드를 만들어 사용할 수 있는데 아직 이 에러 코드에 대해서도 어떻게 정의할지 판단이 잘 서지 않는다. 이 부분도 향후에 좀 더 고려를 해볼 것이다.

 아직 회원가입과 로그인의 기능이 완전히 구현된 것은 아니다. 세션을 이용하여 로그인 상태인 회원만 글을 작성할 수 있게 해야하기 때문에 스프링 시큐리티를 좀 더 공부해보고 인증과 인가 절차를 적용할 예정이다! 그래서 이후에는 먼저 게시판과 게시글에 대한 기능을 구현해볼 것이다.

 


GitHub Link

회원가입 및 로그인 구현 1차

 

Jimin: 회원가입 및 로그인구현 by jmxx219 · Pull Request #7 · PE-Community/study

회원 등록 api 회원 가입 유효성 검사 로그인 api

github.com

회원가입 및 로그인 구현 리팩토링

 

Jimin: 회원가입 및 로그인 리펙토링 by jmxx219 · Pull Request #14 · PE-Community/study

빌더패턴 적용 API 공통 Response 포맷 구현 전역 예외처리 Swagger 적용 회원 비밀번호 암호화 스프링 시큐리티 적용(Form Login)

github.com