Troubleshooting: 무엇이 문제였는가?/본캠프 5주차: Spring 심화 프로젝트

6단계: 예외 처리 메시지를 일일이 작성해야 할까? (수정 중)

writingforever162 2025. 1. 6. 16:29

1. [문제 인식 및 정의]

더보기
package com.example.plan.member7.service;

import com.example.plan.base.BaseEntity;
import com.example.plan.comment7.entity.Comments;
import com.example.plan.comment7.repository.CommentRepository;
import com.example.plan.config.PasswordEncoder;
import com.example.plan.member7.dto.response.*;
import com.example.plan.member7.entity.Member;
import com.example.plan.member7.repository.*;
import com.example.plan.plan7.entity.Plan;
import com.example.plan.plan7.repository.PlanRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;
    private final PlanRepository planRepository;
    private final CommentRepository commentRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    @Override
    public MemberResponseDto signUp(
            String username,
            String email,
            String password
    ) {
        String encodedPassword = passwordEncoder.encode(password);

        Member member = new Member(
                username,
                email,
                encodedPassword
        );

        Member savedMember = memberRepository.save(member);

        return MemberResponseDto.toDto(savedMember);
    }

    @Transactional
    @Override
    public SignInMemberResponseDto signIn(
            String email,
            String password
    ) {
        Member foundMember = memberRepository.findByEmail(email)
                .orElseThrow(() -> new ResponseStatusException(
                                HttpStatus.UNAUTHORIZED
                                , "Email does not Match"
                        )
                );

        boolean isPasswordDifferent = !passwordEncoder
                .matches(
                        password
                        , foundMember.getPassword()
                );

        if (isPasswordDifferent) {
            throw new ResponseStatusException(
                    HttpStatus.UNAUTHORIZED
                    , "Password does not match"
            );
        }

        return new SignInMemberResponseDto(foundMember.getId());
    }

    @Transactional(readOnly = true)
    @Override
    public List<MemberResponseDto> readAllMembers() {

        List<MemberResponseDto> memberList = new ArrayList<>();

        memberList = memberRepository.findAllByIsDeletedFalse()
                .stream()
                .map(MemberResponseDto::toDto)
                .toList();

        return memberList;
    }

    @Transactional(readOnly = true)
    @Override
    public MemberResponseDto readMemberById(Long memberId) {

        Member foundMember = findMemberById(memberId);

        return MemberResponseDto.toDto(foundMember);
    }

    @Transactional
    @Override
    public MemberResponseDto updateMember(
            Long memberId,
            String username,
            String email
    ) {
        Member foundMember = findMemberById(memberId);

        foundMember.update(username, email);

        return MemberResponseDto.toDto(foundMember);
    }

    @Transactional
    @Override
    public void deleteMember(Long memberId) {
        Member foundMember = findMemberById(memberId);

        if (foundMember.getIsDeleted()) {
            throw new ResponseStatusException(
                    HttpStatus.CONFLICT,
                    "The requested data has already been deleted"
            );
        }

        foundMember.markAsDeleted();

        List<Plan> planList = new ArrayList<>();

        planList = planRepository
                .findAllByMemberIdAndIsDeletedFalse(memberId);

        planList.stream()
                .peek(BaseEntity::markAsDeleted)
                .forEach(plan -> {
                            List<Comments> commentsList = commentRepository
                                    .findAllByPlanIdAndIsDeletedFalse(
                                            plan.getId()
                                    );
                            commentsList.forEach(BaseEntity::markAsDeleted);
                        }
                );
    }

    private Member findMemberById(Long memberId) {
        return memberRepository
                .findByIdAndIsDeletedFalse(memberId)
                .orElseThrow(
                        () -> new ResponseStatusException(
                                HttpStatus.NOT_FOUND,
                                "Id doest not exist"
                        )
                );
    }
}
더보기
package com.example.plan.comment7.service;

import com.example.plan.comment7.dto.response.CommentResponseDto;
import com.example.plan.comment7.entity.Comments;
import com.example.plan.comment7.repository.CommentRepository;
import com.example.plan.plan7.entity.Plan;
import com.example.plan.plan7.repository.PlanRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.util.ArrayList;
import java.util.List;

@RequiredArgsConstructor
@Service
public class CommentServiceImpl implements CommentService {
    private final PlanRepository planRepository;
    private final CommentRepository commentRepository;

    @Transactional
    @Override
    public CommentResponseDto createComment(
            String content,
            Long planId
    ) {
        Plan foundPlan = planRepository
                .findByIdAndIsDeletedFalse(planId)
                .orElseThrow(
                        () -> new ResponseStatusException(
                                HttpStatus.NOT_FOUND,
                                "Id does not exist"
                        )
                );

        Comments commentToSave = new Comments(content);

        commentToSave.updatePlan(foundPlan);

        commentToSave.updateMember(foundPlan.getMember());

        Comments savedComment = commentRepository.save(commentToSave);

        return CommentResponseDto.toDto(savedComment);
    }

    @Transactional(readOnly = true)
    @Override
    public List<CommentResponseDto> readAllComments() {

        List<CommentResponseDto> allComments = new ArrayList<>();

        allComments = commentRepository
                .findAllByIsDeletedFalse()
                .stream()
                .map(CommentResponseDto::toDto)
                .toList();

        return allComments;
    }

    @Transactional(readOnly = true)
    @Override
    public CommentResponseDto readCommentById(Long commentId) {

        Comments foundComment = findCommentById(commentId);

        return CommentResponseDto.toDto(foundComment);
    }

    @Transactional
    @Override
    public CommentResponseDto updateComment(
            Long commentId
            , String content
    ) {
        Comments foundComment = findCommentById(commentId);

        foundComment.updateContent(content);

        Comments updatedComment = commentRepository.save(foundComment);

        return CommentResponseDto.toDto(updatedComment);
    }

    @Transactional
    @Override
    public void deleteComment(Long commentId) {
        Comments foundComment = findCommentById(commentId);

        if (foundComment.getIsDeleted()) {
            throw new ResponseStatusException(
                    HttpStatus.CONFLICT,
                    "Requested data has already been deleted"
            );
        }

        foundComment.markAsDeleted();
    }

    private Comments findCommentById(Long commentId) {
        return commentRepository
                .findByIdAndIsDeletedFalse(commentId)
                .orElseThrow(
                        () -> new ResponseStatusException(
                                HttpStatus.NOT_FOUND,
                                "Id does not exist"
                        )
                );
    }
}
더보기
package com.example.plan.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@RestControllerAdvice
public class LayerExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponseMessage> handleValidationException(
        MethodArgumentNotValidException ex
    ) {
        HttpStatusCode statusCode = ex.getStatusCode();

        List<String> errors = new ArrayList<>();

        errors = ex.getFieldErrors()
                .stream()
                .map(error ->
                        error.getField()
                                + " field has an error. "
                                + error.getDefaultMessage()
                )
                .toList();

        return new ResponseEntity<>(
                new ErrorResponseMessage(
                statusCode.value(), 
                String.join(". ", errors)
                ), 
                statusCode
        );
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<ErrorResponseMessage> handleResponseStatusException(
        ResponseStatusException ex
    ) {
        HttpStatusCode statusCode = ex.getStatusCode();

        return new ResponseEntity<>(
                new ErrorResponseMessage(
                statusCode.value(), 
                ex.getReason()
                ), 
                statusCode
        );
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponseMessage> handleException(
        Exception ex
    ) {
        log.error(ex.getMessage(), ex);

        HttpStatusCode statusCode = HttpStatusCode.valueOf(500);

        return new ResponseEntity<>(
                new ErrorResponseMessage(
                statusCode.value(), 
                "Error has occurred."
                ), 
                statusCode
        );
    }
}

예외 처리를 쭉 검토하다가 개발자가 예외 처리를 잘못할 수 있다는 문제를 발견했다. 첫 번째로는 예외 처리를 하나씩 찾으며 수정하다가 Http Status를 잘못 골라서 CONFLICT인데 NOT_FOUND를 고를 가능성이 있었다. 두 번째로는 오류 메시지가 일관되게 전송되어야 하는데 수정하다가 메시지 몇 개를 놓치거나 오탈자를 낼 수 있었다. 즉, 인적 오류가 발생할 수 있는 부분이 총 두 가지였다. 이 두 가지 부분을 개발자가 일일이 찾아서 수정하지 않도록 해야 인적 오류 발생 가능성을 낮출 수 있었다.

 

[의사 결정]

 

 

 

 

[해결 과정]

더보기
package com.example.plan.exception;

public class ErrorMessage {

  public static final String MEMBER_NOT_FOUND = "Member does not exist";
  public static final String PLAN_NOT_FOUND = "Plan does not exist";
  public static final String COMMENT_NOT_FOUND = "Comment does not exist";
  public static final String EMAIL_NOT_MATCH = "Email does not Match";
  public static final String PASSWORD_NOT_MATCH = "Password does not match";
  public static final String DATA_ALREADY_DELETED = "Requested data has already been deleted";
  public static final String INVALID_PATH = "Please check input path";

  public static final String ERROR_MEMBER_NOT_FOUND = "ERROR_MEMBER_NOT_FOUND";
  public static final String ERROR_PLAN_NOT_FOUND = "ERROR_PLAN_NOT_FOUND";
  public static final String ERROR_COMMENT_NOT_FOUND = "ERROR_COMMENT_NOT_FOUND";
  public static final String ERROR_EMAIL_NOT_MATCH = "ERROR_EMAIL_NOT_MATCH";
  public static final String ERROR_PASSWORD_NOT_MATCH = "ERROR_PASSWORD_NOT_MATCH";
  public static final String ERROR_DATA_ALREADY_DELETED = "ERROR_DATA_ALREADY_DELETED";
  public static final String ERROR_INVALID_PATH = "ERROR_INVALID_PATH";
  public static final String ERROR_INVALID_INPUT = "ERROR_INVALID_INPUT";
}
더보기
package com.example.plan.exception;

public class AlreadyDeletedException extends RuntimeException {

  public AlreadyDeletedException(String message) {
    super(message);
  }
}
package com.example.plan.exception;

public class CommentNotFoundException extends RuntimeException {

  public CommentNotFoundException(String message) {
    super(message);
  }
}
package com.example.plan.exception;

public class EmailMismatchException extends RuntimeException {

  public EmailMismatchException(String message) {
    super(message);
  }
}
package com.example.plan.exception;

public class MemberNotFoundException extends RuntimeException {

  public MemberNotFoundException(String message) {
    super(message);
  }
}
package com.example.plan.exception;

public class PasswordMismatchException extends RuntimeException {

  public PasswordMismatchException(String message) {
    super(message);
  }
}
package com.example.plan.exception;

public class PlanNotFoundException extends RuntimeException {

  public PlanNotFoundException(String message) {
    super(message);
  }
}
더보기
package com.example.plan.exception;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<Map<String, Object>> handleValidationException(
      MethodArgumentNotValidException e
  ) {

    List<String> errors = new ArrayList<>();

    errors = e.getFieldErrors()
        .stream()
        .map(DefaultMessageSourceResolvable::getDefaultMessage
        )
        .toList();

    return handleException(
        new Exception(String.join(". ", errors)),
        ErrorMessage.ERROR_INVALID_INPUT,
        HttpStatus.BAD_REQUEST
    );
  }

  @ExceptionHandler(Exception.class)
  public ResponseEntity<Map<String, Object>> handleOtherException() {
    return handleException(
        new Exception(ErrorMessage.INVALID_PATH),
        ErrorMessage.ERROR_INVALID_PATH,
        HttpStatus.NOT_FOUND
    );
  }

  @ExceptionHandler(EmailMismatchException.class)
  public ResponseEntity<Map<String, Object>> handleEmailMismatchException(
      EmailMismatchException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_EMAIL_NOT_MATCH,
        HttpStatus.UNAUTHORIZED
    );
  }

  @ExceptionHandler(PasswordMismatchException.class)
  public ResponseEntity<Map<String, Object>> handlePasswordMismatchException(
      PasswordMismatchException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_PASSWORD_NOT_MATCH,
        HttpStatus.UNAUTHORIZED
    );
  }

  @ExceptionHandler(MemberNotFoundException.class)
  public ResponseEntity<Map<String, Object>> handleMemberNotFoundException(
      MemberNotFoundException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_MEMBER_NOT_FOUND,
        HttpStatus.NOT_FOUND
    );
  }

  @ExceptionHandler(PlanNotFoundException.class)
  public ResponseEntity<Map<String, Object>> handlePlanNotFoundException(
      PlanNotFoundException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_PLAN_NOT_FOUND,
        HttpStatus.NOT_FOUND
    );
  }

  @ExceptionHandler(CommentNotFoundException.class)
  public ResponseEntity<Map<String, Object>> handleCommentNotFoundException(
      CommentNotFoundException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_COMMENT_NOT_FOUND,
        HttpStatus.NOT_FOUND
    );
  }

  @ExceptionHandler(AlreadyDeletedException.class)
  public ResponseEntity<Map<String, Object>> handleAlreadyDeletedException(
      AlreadyDeletedException ex
  ) {
    return handleException(
        ex,
        ErrorMessage.ERROR_DATA_ALREADY_DELETED,
        HttpStatus.CONFLICT
    );
  }

  private ResponseEntity<Map<String, Object>> handleException(
      Exception ex,
      String errorCode,
      HttpStatus status
  ) {
    String errorMessage = ex.getMessage();

    Map<String, Object> errorResponse = new LinkedHashMap<>();
    errorResponse.put("errorCode", errorCode);
    errorResponse.put("errorMessage", errorMessage);

    return new ResponseEntity<>(errorResponse, status);
  }
}

 

 

 

더보기
package com.example.plan.exception;

public class AlreadyDeletedException extends RuntimeException {

  public AlreadyDeletedException() {
    super(ErrorMessage.DATA_ALREADY_DELETED);
  }
}
package com.example.plan.exception;

public class CommentNotFoundException extends RuntimeException {

  public CommentNotFoundException() {
    super(ErrorMessage.COMMENT_NOT_FOUND);
  }
}
package com.example.plan.exception;

public class EmailMismatchException extends RuntimeException {

  public EmailMismatchException() {
    super(ErrorMessage.EMAIL_NOT_MATCH);
  }
}
package com.example.plan.exception;

public class MemberNotFoundException extends RuntimeException {

  public MemberNotFoundException() {
    super(ErrorMessage.MEMBER_NOT_FOUND);
  }
}
package com.example.plan.exception;

public class PasswordMismatchException extends RuntimeException {

  public PasswordMismatchException() {
    super(ErrorMessage.PASSWORD_NOT_MATCH);
  }
}
package com.example.plan.exception;

public class PlanNotFoundException extends RuntimeException {

  public PlanNotFoundException() {
    super(ErrorMessage.PLAN_NOT_FOUND);
  }
}

 

 

 

 

---

더보기
package com.example.plan.exception.notfound;

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

@Getter
public class NotFoundException extends RuntimeException {

  private final String errorCode;
  private final HttpStatus httpStatus;

  public NotFoundException(String message) {
    super(message);
    this.errorCode = "ERROR_NOT_FOUND";
    this.httpStatus = HttpStatus.NOT_FOUND;
  }
}

 

더보기
package com.example.plan.exception.notfound;

import com.example.plan.exception.ErrorMessage;

public class CommentNotFoundException extends NotFoundException {

  public CommentNotFoundException() {
    super(ErrorMessage.COMMENT_NOT_FOUND);
  }
}
package com.example.plan.exception.notfound;

import com.example.plan.exception.ErrorMessage;

public class MemberNotFoundException extends NotFoundException {

  public MemberNotFoundException() {
    super(ErrorMessage.MEMBER_NOT_FOUND);
  }
}
package com.example.plan.exception.notfound;

import com.example.plan.exception.ErrorMessage;

public class PlanNotFoundException extends NotFoundException {

  public PlanNotFoundException() {
    super(ErrorMessage.PLAN_NOT_FOUND);
  }
}

 

 

 

 

 

 

 

 

더보기
package com.example.plan.exception;

public class ErrorMessage {

  public static final String MEMBER_NOT_FOUND = "Member is not found";
  public static final String PLAN_NOT_FOUND = "Plan is not found";
  public static final String COMMENT_NOT_FOUND = "Comment is not found";
  public static final String EMAIL_NOT_MATCH = "Email mismatches";
  public static final String PASSWORD_NOT_MATCH = "Password mismatch";
  public static final String DATA_ALREADY_DELETED = "Requested data has already been deleted";
  public static final String INVALID_PATH = "Please check input path";
}

 

 

더보기
package com.example.plan.exception;

import com.example.plan.exception.mismatch.MismatchException;
import com.example.plan.exception.notfound.NotFoundException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<Map<String, Object>> handleValidationException(
      MethodArgumentNotValidException e
  ) {

    List<String> errors = new ArrayList<>();

    errors = e.getFieldErrors()
        .stream()
        .map(DefaultMessageSourceResolvable::getDefaultMessage
        )
        .toList();

    return handleException(
        new Exception(String.join(". ", errors)),
        "ERROR_INVALID_INPUT",
        HttpStatus.BAD_REQUEST
    );
  }

  @ExceptionHandler(Exception.class)
  public ResponseEntity<Map<String, Object>> handleOtherException() {
    return handleException(
        new Exception(ErrorMessage.INVALID_PATH),
        "ERROR_INVALID_PATH",
        HttpStatus.NOT_FOUND
    );
  }

  @ExceptionHandler(MismatchException.class)
  public ResponseEntity<Map<String, Object>> handleUnAuthorizedException(
      MismatchException ex
  ) {
    return handleException(
        ex,
        ex.getErrorCode(),
        ex.getHttpStatus()
    );
  }

  @ExceptionHandler(NotFoundException.class)
  public ResponseEntity<Map<String, Object>> handleNotFoundException(
      NotFoundException ex
  ) {
    return handleException(
        ex,
        ex.getErrorCode(),
        ex.getHttpStatus()
    );
  }

  @ExceptionHandler(AlreadyDeletedException.class)
  public ResponseEntity<Map<String, Object>> handleAlreadyDeletedException(
      AlreadyDeletedException ex
  ) {
    return handleException(
        ex,
        ex.getErrorCode(),
        ex.getHttpStatus()
    );
  }

  private ResponseEntity<Map<String, Object>> handleException(
      Exception ex,
      String errorCode,
      HttpStatus status
  ) {
    String errorMessage = ex.getMessage();

    Map<String, Object> errorResponse = new LinkedHashMap<>();
    errorResponse.put("errorCode", errorCode);
    errorResponse.put("errorMessage", errorMessage);

    return new ResponseEntity<>(errorResponse, status);
  }
}