코드 리팩토링을 하기 전 문제 상황
Ice Advice 프로젝트는 제가 처음으로 백엔드 개발자로 협업을 하게 된 프로젝트입니다.
이 프로젝트는 코딩존이라는 멘토링 예약 시스템을 예약 및 출석 관리해주는 시스템입니다.
이 출석 횟수는 실제 과목 성적에 영향을 주기에 시스템 운영에 따라 학교 성적 처리에 영향을 줄 수 있을만큼 중요한 작업이었습니다.
아무것도 모르던 백엔드 개발자로서 좋은 사람들을 만나서 개발을 시작할 수 있었습니다.
그때는 유튜브와 같은 영상을 통해서 학습하고 개발하던게 기억에 남습니다.
이후 시간이 흘러 다시 이 프로젝트를 개선하는 작업을 맡게되었습니다.
이해를 돕기 위해 아래 코드를 보고 개선점을 도출해보도록 하겠습니다.
컨트롤러
@RestController
@RequestMapping("/**/**")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("")
public ResponseEntity<? super GetSignInUserResponseDto> getSignInUser (
@AuthenticationPrincipal String email
){
ResponseEntity<? super GetSignInUserResponseDto> response = userService.getSignInUser(email);
return response;
}
@PatchMapping("")
public ResponseEntity<? super PatchUserResponseDto> patchUser(
@RequestBody @Valid PatchUserRequestDto requestBody,
@AuthenticationPrincipal String email
){
ResponseEntity<? super PatchUserResponseDto> response = userService.patchUser(requestBody, email);
return response;
}
// .. 다른 코드
}
비즈니스 로직
@Service
@RequiredArgsConstructor
public class UserServiceImplement implements UserService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
public ResponseEntity<? super GetSignInUserResponseDto> getSignInUser(String email) {
User userEntity = null;
try {
userEntity = userRepository.findByEmail(email);
if (userEntity == null ) return GetSignInUserResponseDto.notExistUser();
} catch(Exception exception) {
exception.printStackTrace();
return ResponseDto.databaseError();
}
return GetSignInUserResponseDto.success(userEntity);
}
@Override
public ResponseEntity<? super PatchUserResponseDto> patchUser(PatchUserRequestDto dto, String email){
try{
User userEntity = userRepository.findByEmail(email);
if(userEntity == null) return PatchUserResponseDto.notExistUser();
userEntity.patchUser(dto);
userRepository.save(userEntity);
}catch(Exception exception){
exception.printStackTrace();
return ResponseDto.databaseError();
}
return PatchUserResponseDto.success();
}
// .. 다른 코드
}
응답 DTO
@Getter
public class GetSignInUserResponseDto extends ResponseDto {
private String email;
private String studentNum;
private String name;
private GetSignInUserResponseDto(User userEntity) {
super(ResponseCode.SUCCESS, ResponseMessage.SUCCESS);
this.email = userEntity.getEmail();
this.studentNum = userEntity.getStudentNum();
this.name = userEntity.getName();
}
public static ResponseEntity<GetSignInUserResponseDto> success(User userEntity) {
GetSignInUserResponseDto result = new GetSignInUserResponseDto(userEntity);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
public static ResponseEntity<ResponseDto> notExistUser() {
ResponseDto result = new ResponseDto(ResponseCode.NOT_EXISTED_USER, ResponseMessage.NOT_EXISTED_USER);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(result);
}
}
문제점 정의 및 해결
1. 예외 관련 응답을 DTO 단위에서 만들어서 Return
문제 정의
DTO는 레이어 간의 전송 객체입니다. 값을 전달하기 위해서 객체 생성과 객체의 필드 값을 받아오는 것에 대한 책임만 있다고 생각합니다.
따라서 Getter 메서드나 생성자와 같은 기본적인 기능만 제공해야합니다.
현재 코드는 DTO에서 예외를 직접 생성해서 처리하고 있습니다. DTO는 API 별로 구분되어 있으며, 이렇게 될 경우 DTO 별로 예외 메세지를 담은 DTO를 생성해줘야합니다.
`throw new 예외` 형태가 아닌 이렇게 반환하는 것은 로그를 통합적으로 관리하기도 어렵습니다.
현재는 return으로 예외 메세지를 담은 DTO를 컨트롤러에 전달하기에 반환 타입 또한 명확하지 않습니다.
그 이유는 위에서 얘기했듯이 예외 메세지를 담은 DTO도 전달해야했기 때문입니다.
해결 방법
커스텀 예외를 만들어서 비즈니스 로직에서 어긋나는 부분을 처리하게 하여 에러와 응답이라는 역할을 분리해야합니다.
다만 현재 프론트 코드의 비즈니스 로직과 강결합 되어 있는 부분의 수정을 최소화해야합니다.
따라서 기존의 비즈니스 로직에서 DTO를 통한 예외 발생 응답 값을 HTTP 응답 메세지에 담았다면,
예외가 발생할 경우 전달되는 값을 아래서 후술할 전역 예외 처리기와의 조합으로 책임을 분리했습니다.
또한 무의미한 응답 객체를 생성하는 것을 막기 위해서 공통 DTO를 생성해주었습니다.
커스텀 예외 코드 구현
package com.icehufs.icebreaker.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BusinessException extends RuntimeException {
private final String code; // 응답용 에러 코드 (예: "NE", "DE", "DBE")
private final HttpStatus status; // HTTP 상태 코드 (예: 400, 403, 500)
public BusinessException(String code, String message, HttpStatus status) {
super(message);
this.code = code;
this.status = status;
}
}
공통 응답 DTO 구현
package com.icehufs.icebreaker.util;
import lombok.Getter;
@Getter
public class ResponseDto<T> {
private final String code;
private final String message;
private T data;
public ResponseDto(String code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ResponseDto<T> success() {
return new ResponseDto<>("SU", "Success.", null);
}
public static <T> ResponseDto<T> success(String message) {
return new ResponseDto<>("SU", message, null);
}
public static <T> ResponseDto<T> success(T data) {
return new ResponseDto<>("SU", "Success", data);
}
public static <T> ResponseDto<T> success(String message, T data) {
return new ResponseDto<>("SU", message, data);
}
public static <T> ResponseDto<T> success(String code, String message) {
return new ResponseDto<>(code, message, null);
}
public static <T> ResponseDto<T> fail(String code, String message) {
return new ResponseDto<>(code, message, null);
}
public static <T> ResponseDto<T> fail(String code, String message, T data) {
return new ResponseDto<>(code, message, data);
}
}
2. 비즈니스 로직의 무분별한 try-catch 작성과 비즈니스 로직 에러를 DB 에러로 처리하는 로직
문제 정의
try-catch가 무분별하게 존재하는건 어떻게 생각하시나요?
try - cathch를 통해서 DB 관련 에러를 처리하고 있습니다. 그러나 DB 에러를 처리하기 위한 로직이 따로 존재하지않고 클라이언트에게 전달하기 위해서 try-catch로 담는 것이라면 위와 같이 처리하는 것은 올바르지않습니다.
본래 Spring에서는 에러가 발생하면 컨트롤러로 전달 된 뒤 BasicErrorController에서 에러를 처리하게 됩니다. 따라서 try-catch를 현재 프로젝트에서 사용하는 것은 올바르지않습니다.
이 문제는 Cotroller에서 예외를 통합적으로 처리할 수 있는 @ControllerAdvice + @ExceptionHandler를 사용해서 해결이 가능합니다.
스프링의 AOP를 활용하면 해결할 수 있는 문제이기에 적극적으로 사용해야합니다.
해결방법
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ResponseDto<Void>> handleBusinessException(BusinessException e) {
log.warn("[비즈니스 로직 에러 발생] {}", e.getMessage());
return ResponseEntity
.status(e.getStatus())
.body(ResponseDto.fail(e.getCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ResponseDto<Void>> handleUnexpectedException(Exception e) {
log.warn("[비즈니스에서 잡지 못하는 에러 발생] {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR) // ex.getStatus()는 HttpStatus 반환하도록 정의
.body(ResponseDto.fail("IE", "서버 내부 오류가 발생했습니다."));
}
@ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
public ResponseEntity<ResponseDto<Void>> handleValidationException(Exception e){
log.warn("[요청 HTTP BODY 검증 에러] {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ResponseDto.fail("VF", "Validation failed."));
}
@ExceptionHandler({ DataAccessException.class, SQLException.class })
public ResponseEntity<ResponseDto<Void>> handleDatabaseException(Exception e) {
log.warn("[DB 에러] {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ResponseDto.fail("DBE", "Database error."));
}
}
3. 자동 주입을 사용하지않는 필드 값 존재
필드 값 중에서 passwordEncoder라는 필드가 있습니다.
현재는 서비스 레이어에서 passwordEncoder 객체를 직접 생성해서 사용하고 있습니다.
이는 스프링의 빈 객체로 만들어서 관리가 충분히 가능합니다. 따라서 서비스 레이어마다 굳이 독립적으로 관리할 필요가 없습니다.
빈 메서드를 활용한 스프링 컨테이너 관리로 수정
@RequiredArgsConstructor
public class WebSecurityConfig {
// .. 다른 코드
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// .. 다른 코드
}
자동 주입된 PasswordEncoder
@Service
@RequiredArgsConstructor
public class UserServiceImplement implements UserService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final PasswordEncoder passwordEncoder;
// .. 코드 생략
}
결론: 책임을 분리한 예외와 성공적인 응답 값 분리
위에서 커스텀 비즈니스 예외와 전역 예외 응답처리기를 만들어주었습니다. 이를 바탕으로 개선하게 된 서비스 레이어의 비즈니르 로직은 아래와 같습니다.
컨트롤러
@RestController
@RequestMapping("/**/**")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("")
public ResponseEntity<ResponseDto<GetSignInUserResponseDto>> getSignInUser(
@AuthenticationPrincipal String email //확인하고자하는 유저의 토큰 유효성 확인 후 유저의 메일 반환
){
return ResponseEntity.ok(ResponseDto.success(userService.getSignInUser(email)));
}
@PatchMapping("")
public ResponseEntity<ResponseDto<String>> patchUser(
@RequestBody @Valid PatchUserRequestDto requestBody,
@AuthenticationPrincipal String email
){
return ResponseEntity.ok(ResponseDto.success(userService.patchUserInfo(requestBody, email)));
}
// .. 다른 코드
}
비즈니스 로직
@Service
@RequiredArgsConstructor
public class UserServiceImplement implements UserService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final PasswordEncoder passwordEncoder;
@Override
public GetSignInUserResponseDto getSignInUser(String email) {
User userEntity = userRepository.findByEmail(email);
if (userEntity == null) throw new BusinessException(ResponseCode.NOT_EXISTED_USER, ResponseMessage.NOT_EXISTED_USER, HttpStatus.UNAUTHORIZED);
return new GetSignInUserResponseDto(userEntity.getEmail(), userEntity.getStudentNum(), userEntity.getName());
}
@Override
public String patchUserInfo(PatchUserRequestDto dto, String email){
User userEntity = userRepository.findByEmail(email);
if (userEntity == null) throw new BusinessException(ResponseCode.NOT_EXISTED_USER, ResponseMessage.NOT_EXISTED_USER, HttpStatus.UNAUTHORIZED);
userEntity.patchUser(dto);
userRepository.save(userEntity);
return "사용자 계정이 성공적으로 수정되었습니다.";
}
// .. 다른 코드
}
데이터를 전달해야하는 API의 응답 DTO
public record GetSignInUserResponseDto(String email,String studentNum, String name) {}
응답 데이터가 성공적이었다는 의미만 담고 있다면, 공통 DTO를 사용하였습니다.
데이터가 담겨서 전달되어야한다면 record 객체를 활용해서 값을 반환해주었습니다.
record는 Java17부터 도입된 객체로써 필드의 불변성, Getter, 생성자를 추상화해서 사용할 수 있게 해줍니다.
물론 Lombok의 @Getter/@AllArgsConstructor를 사용할 수 있겠지만, 불필요하게 외부 라이브러리에 의존하지않게 작성하였습니다.
정리
- 응답 DTO는 성공적인 메세지에 대한 전달을 위한 책임을, 전역 예외 처리기는 예외가 발생하여 문제 상황을 전달하는 책임을 분리하였습니다. 따라서 개발자는 비즈니스 로직에 집중하여 작성할 수 있게 되었습니다.
- 공통 DTO를 통해서 별다른 의미가 없는 응답 객체를 반복해서 생성하지않게 했습니다.
- 의미없는 중복된 로직을 작성해야하는 로직에서 비즈니스 로직에만 집중할 수 있는 로직으로 개선하였습니다.
'WEB > 트러블슈팅' 카테고리의 다른 글
[트러블 슈팅] 회원 600명 부하테스트 진행하기 with K6 (0) | 2025.03.21 |
---|---|
[트러블 슈팅] 외부에서의 redis 접근으로 인한 복제 노드로 변환되는 문제 (0) | 2025.03.21 |
[트러블 슈팅] 복합키 인덱스 최적화 (0) | 2025.01.28 |
[트러블 슈팅] RTR 도입기 (0) | 2025.01.27 |
[트러블 슈팅] MySQL 시간대(Timezone) 설정 이슈 (0) | 2025.01.07 |