목차
- 개발 환경
- 회원가입 및 로그인 기능 구현
- 리팩토링
- 빌더패턴 적용
- 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를 따로 만들어두었다.
- 관심사의 분리 -
DTO와Entity
- 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 정상 응답 및 에러에 대한 응답 포맷이 다르게 되면 클라이언트는 해당 응답이 성공했는지 실패했는지 바로 알기가 어려울 것 같다는 생각이 들었다. 하지만 정상과 에러에 대한 응답 포맷이 같다면 둘을 구분하기도 쉽고, 서버도 또한 항상 같은 포맷 형태로 반환하면 되기 때문에 편할 것이다.
- [고려] 엔티티 생성
- [참고] Entity 생성 방법
- 공부한 내용을 바탕으로 빌더패턴 적용해볼 것이다.
- [고려] 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이 발생한 경우 에러를 발생시키지 않고 클라이언트에 에러 응답 데이터를 전송할 것이다.
동작 과정
- 클라이언트가 API를 호출한다.
- Controller에서 해당 API에 대한 처리를 수행한다.
- 데이터 처리가 정상적으로 처리된 경우
- 비즈니스 로직 처리를 완료하고 클라이언트로 성공 응답 데이터를 전송한다.
- 데이터가 정상처리 되지 않고, Exception이 발생한 경우
GlobalExceptionHandler에서 에러를 캐치하여, 클라이언트로 에러 응답 데이터를 전송한다.
- 데이터 처리가 정상적으로 처리된 경우
- 클라이언트와 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에서 처리한다.
- Bean Validation으로 검증으로 예외가 발생한 경우에
- 전역 에러 핸들러(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
'Project > PE-Community' 카테고리의 다른 글
[PE-Community] 계층형 댓글 구현 (0) | 2023.11.09 |
---|---|
[PE-Community] 게시글 및 게시판 구현(+파일 등록) (0) | 2023.10.25 |
[PE-Community] Spring Security와 JWT 적용 (1) | 2023.10.16 |
[PE-Community] 기능 정의 및 DB 설계 (0) | 2023.08.22 |
[PE-Community] 토이 프로젝트 시작하기(주제 선정) (1) | 2023.08.21 |