🔌 SPARTA/Assignments

[Spring 과제] 코드 개선

eunjiom 2026. 3. 4. 21:57

Lv 0. 에러 분석

1. 프로젝트 실행

 

1) ExpertApplication

package org.example.expert;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.web.config.EnableSpringDataWebSupport;

import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;

@SpringBootApplication
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class ExpertApplication {

    public static void main(String[] args) {

        SpringApplication.run(ExpertApplication.class, args);
    }

}

 

2) 에러 발생

Exception: org.springframework.beans.factory. UnsatisfiedDependencyException. 
Message: Error creating bean with name 'filterConfig' defined in file ...
: Unsatisfied dependency expressed through constructor parameter 0
: Error creating bean with name 'jwtUtil'
: Injection of autowired dependencies failed
  • Exception: 의존성 주입 실패 예외
  • message: FilterConfig 생성 실패
  • : 생성자 매개변수 0 (생성자에 필요한 객체가 없음)
  • : jwtUtil 생성 실패
  • : 의존성 주입 실패

3) FilterConfig 클래스

package org.example.expert.config;

@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;
    private final ObjectMapper objectMapper;

    @Bean
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JwtFilter(jwtUtil, objectMapper));
        registrationBean.addUrlPatterns("/*");

        return registrationBean;
    }
}
  • JwtUtil을 의존하고 있으며 생성자 주입을 통해 JwtUtil Bean을 주입받음

2. 문제

 

1) JwtUtil 클래스 코드

더보기
package org.example.expert.config;

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        throw new ServerException("Not Found Token");
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

2) 문제 코드

@Value("${jwt.secret.key}")
private String secretKey;
  • application.yml(또는 properties)에서 jwt.secret.key 라는 값을 찾아서 secretKey에 넣어주는 기능\
  • application.yml 사용 이유
    : 비밀값을 프로그램 코드 안에 작성하면 위험함 > 외부 파일로 분리
    : 환경마다 값이 달라 바뀔 때마다 수정해야 하니 설정 파일에 값을 넣어 설정 파일만 변경하게끔 설정
    : ${} = 설정 파일을 찾음

3. 해결

 

1) application.yml 생성

  • resources 설정파일 경로 생성
  • application.yml 파일 생성

2) Secret Key 조건

byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
  • Base64 문자열 사용
  • 32바이트 이상 키 필요

3) Base64 문자열 랜덤 생성

 

Base64 Encode and Decode - Online

Encode to Base64 format or decode from it with various advanced options. Our site has an easy to use online tool to convert your data.

www.base64encode.org

  • 랜덤 생성

 

4) JWT Secret Key 설정 추가

jwt:
  secret:
    key: 6rO87KCcIO2VmOq4sCDsi6vslrTsmpQg64SI66y0IOyWtOugpOybjOyalCDtnZHtnZE=

 

4. 트러블 슈팅

 

1) 문제: Secret Key 설정 추가 했으나 실행했을 때 에러 발생하며 실행 안 됨

WARN 20556 --- [main] ConfigServletWebServerApplicationContext 
: Exception encountered during context initialization - cancelling refresh attempt
// 스프링 앱 시작 준비하다가 예외 때문에 시작 취소함
: Error creating bean with name 'entityManagerFactory' 
// JPA가 쓰는 엔티티매니저 공장 만들다가 에러
: Failed to initialize dependency 'dataSourceScriptDatabaseInitializer' 
// dataSourceScriptDatabaseInitializer 초기화 X
: Error creating bean with name 'dataSourceScriptDatabaseInitializer' defined in class path resource 
: Error creating bean with name 'dataSource' defined in class path resource 
// DB 연결 객체 생성 실패
: Failed to instantiate [com.zaxxer.hikari.HikariDataSource]
: Factory method 'dataSource' threw exception with message: Failed to determine a suitable driver class
// DataSource 생성 실패 (DB 드라이버 의존성 누락 또는 datasource 설정 누락 시 발생)

 

2) 해결: DataSource 설정 추가

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/expert
    username: root
    password: 12345678
    driver-class-name: com.mysql.cj.jdbc.Driver

 

Lv 1. ArgumentResolver

 

1.  ArgumentResolver 역할

  • AuthUser(userId, email, role) : 로그인한 사용자 정보
  • 서버는 요청이 오면 JWT 토큰만 받음
토큰
↓
사용자 정보(userId, email, role) 추출
↓
AuthUser 객체 생성
  • 이걸 담당하는 게 ArgumentResolver

2. 문제

  • 코드
package org.example.expert.config;

// AuthUserArgumentResolver 역할하는 클래스 선언
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    // 파라미터 처리할 건지 물어봄(true -> 내가 처리)
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 파라미터에 @Auth가 붙어있으면 true
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        // 파라미터 타입이 AuthUser면 true
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

        // @Auth 붙은 파라미터 처리
        return hasAuthAnnotation;
    }

    // authUser 만들어서 주는 공장
    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        // 요청 꺼내기(헤더,쿠키,정보)
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");
        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));

        return new AuthUser(userId, email, userRole);
    }
}
  • 요청 꺼내서 userId, email, userRole 통해 AuthUser 만들 수 있게끔 설정되어 있음
  • 그러나 ArgumentResolver는 자동 동작 X -> WebMvcConfigurer를 통해 Resolver를 등록하여 동작하도록 설정

3) JwtFilter 클래스

public class JwtFilter implements Filter

httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
  • request에 userId, email, userRole을 넣고 있으므로 AuthUserArgumentResolver가 사용할 데이터는 정상적으로 전달

3. 해결

 

1) WebConfig

  • 역할: 스프링 MVC 설정 파일
package org.example.expert.config;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {

        resolvers.add(new AuthUserArgumentResolver());
    }
}
  • @Configuration 사용: 클래스 설정 파일 선언
  • WebMvcConfigurer: MVC 설정
  • @Override: 부모 메서드 재정의
  • Spring이 사용하는 ArgumentResolver 목록(List)을 addArgumentResolvers() 메서드를 통해 전달받아, 
    해당 목록에 AuthUserArgumentResolver를 추가

2) 전체적인 흐름

클라이언트 요청
↓
JwtFilter (필터)
↓
request.setAttribute()로 사용자 정보 저장
↓
WebConfig (Resolver 등록)
↓
AuthUserArgumentResolver 실행
↓
AuthUser 생성
↓
Controller 파라미터 전달

 

Lv 2 코드 개선

1. Early Return

 

1) 문제

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {

        // 비밀번호 암호화(인코딩)
        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        // 역할 생성
        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        // 이메일 중복 확인
        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );
        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

        return new SignupResponse(bearerToken);
    }
  • 순서 문제 : 기존 코딩 흐름
비밀번호 인코딩 실행
↓
역할 생성
↓
이메일 중복 확인
↓
"이미 존재하는 이메일입니다" 예외 발생
↓
회원가입 실패
  • 이메일 중복이 걸렸을 때 가입 실패지만, encode() 가 먼저 실행되어 불필요한 로직이 실행되고 불필요한 연산 발생
  • 성능 낭비

2) 해결

  • 순서 변경: 올바른 코딩 흐름
이메일 중복 검사
↓
중복 시 예외 발생 > 종료
↓
비밀번호 인코딩
↓
역할 생성
↓
유저 생성
↓
저장
  • 코드 순서 변경
    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {

        // 이메일 중복 확인
        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        // 비밀번호 암호화(인코딩)
        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        // 역할 생성
        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );
        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

        return new SignupResponse(bearerToken);
    }

 

2. 불필요한 if-else 피하기

 

1) 문제

// body 꺼내서 오늘 날씨 목록에 넣음
WeatherDto[] weatherArray = responseEntity.getBody();

// 응답코드가 ok(200)이 아니면 예외처리(상태코드 출력)
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
    throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
} else {

// body가 null이거나 배열길이가 0이면 예외처리
    if (weatherArray == null || weatherArray.length == 0) {
        throw new ServerException("날씨 데이터가 없습니다.");
    }
}
  • else 가 필요한 경우: 조건을 두고 서로 다른 행동을 해야할 때
  • else 가 필요없는 경우: 조건이 실패하면 바로 끝나는 경우
  • 응답 200이 아닌 경우 예외처리하고 끝남 = else 필요없음
  • 기준: if 안에서 return / throw가 있으면 else는 거의 필요 없음

2) 해결

  • else 제거
        // body 꺼내서 오늘 날씨 목록에 넣음
        WeatherDto[] weatherArray = responseEntity.getBody();

        // 응답코드가 ok(200)이 아니면 예외처리(상태코드 출력)
        if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
            throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
        }
        
        // body가 null이거나 배열길이가 0이면 예외처리
        if (weatherArray == null || weatherArray.length == 0) {
                throw new ServerException("날씨 데이터가 없습니다.");
        }

 

3. Validation

 

1) 문제

	if (userChangePasswordRequest.getNewPassword().length() < 8 ||
	// 길이가 8보다 작으면 실패
        !userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
        // 문자 사이 숫자가 하나도 없으면 실패(.*: 아무문자, \d: 숫자, .*: 아무문자)
        !userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
        // 대문자가 하나도 없으면 실패
    throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
}
  • 입력값 검증을 Service에서 진행 > 코드가 지저분해짐
  • 서비스 = 비밀번호 변경, DB 저장, 기존 비밀번호 확인(비지니스 로직)
  • 입력값 검증 로직 != 비지니스 로직 > DTO에서 검증 처리(Validation)

2) 해결

  • DTO에 입력값 검증 로직 입력
    @NotBlank
    private String oldPassword;

    @NotBlank
    @Pattern(regexp = "^(?=.*\\d)(?=.*[A-Z]).{8,}$",
            message = "새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.")
    private String newPassword;
  • Controller에 @Valid 어노테이션으로 DTO 검증 실행
    @PutMapping("/users")
    public void changePassword(@Auth AuthUser authUser, @Valid @RequestBody UserChangePasswordRequest userChangePasswordRequest) {
        userService.changePassword(authUser.getId(), userChangePasswordRequest);
    }
  • Service에 입력값 검증 로직 삭제

Lv 3. N+1 문제

1. N+1

 

1) 개념(예시)

  • 숙제 목록(Todo)와 작성자(User) : 숙제 하나에 작성자가 있음
숙제 목록
1. 수학 숙제 - 철수
2. 영어 숙제 - 영희
3. 과학 숙제 - 민수
  • N+1 방식
1. 선생님: 숙제 목록 다 가져와
2. 숙제 목록만 가져옴(수학, 영어, 과학) = DB조회 1번
3. 선생님: 수학 숙제 작성자 누구야?
4. DB 조회 = 철수
5. 선생님: 영어 숙제 작성자 누구야?
6. DB 조회 = 영희
7. 선생님: 과학 숙제 작성자 누구야?
8. DB 조회 = 민수
  • 결과: 숙제 조회 1번, 작성자 조회 3번

2) 문제점

  • 숙제가 여러개면 1+100...=100...1번 DB 조회
  • 서버 느려짐

3) fetch join과 @EntityGraph

  • 숙제랑 작성자랑 같이 가져오자 = DB 1번 조회
  • fetch join: JPQL로 “연관까지 같이 가져와”를 직접 적는 방식
Repository
@Query("select t from Todo t join fetch t.user where t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
  • @EntityGraph: 어노테이션으로 “연관까지 같이 가져와”를 지정하는 방식
Repository
@EntityGraph(attributePaths = {"user"})

 

2. 문제

 

1) 과제

  • fetch join 코드 > EntityGraph 코드로 변경

2) 기존 코드(fetch join)

public interface TodoRepository extends JpaRepository<Todo, Long> {

    @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC")
    Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

    @Query("SELECT t FROM Todo t " +
            "LEFT JOIN FETCH t.user " +
            "WHERE t.id = :todoId")
    Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);

    int countById(Long todoId);
  • 페이징(Page)에서는 fetch join이 문제를 만들 수 있어 EntityGraph로 변경
  • fetch join은 연관 데이터를 붙이면서 결과 행이 늘어날 수 있어서, Page 페이징과 같이 쓰면 DB가 정확히 자르기 어려워
    메모리 페이징이나 중복 문제가 생길 수 있음

3) 변경 코드(@EntityGraph)

public interface TodoRepository extends JpaRepository<Todo, Long> {

    @EntityGraph(attributePaths = {"user"})
    Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

    @EntityGraph(attributePaths = {"user"})
    Optional<Todo> findByIdWithUser(Long todoId);

    int countById(Long todoId);
  • fetch join(@Query, @Param)삭제 > @EntityGraph 사용

3. 트러블 슈팅

 

1) 메서드 이름

  • 오류 코드
  @Transactional(readOnly = true)
    public TodoResponse getTodo(long todoId) {
        Todo todo = todoRepository.findByIdWithUser(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));
    @EntityGraph(attributePaths = {"user"})
    Optional<Todo> findByIdWithUser(Long todoId);
  • 에러 코드
더보기

: Could not create query for public abstract java.util.Optional org.example.expert.domain.todo.repository.TodoRepository.findByIdWithUser(java.lang.Long); Reason: Failed to create query for method public abstract java.util.Optional org.example.expert.domain.todo.repository.TodoRepository.findByIdWithUser(java.lang.Long); No property 'withUser' found for type 'Long'; Traversed path: Todo.id

 

  • 스프링이 findByIdWithUser라는 메서드 이름을 보고 자동 쿼리(파생쿼리)를 만들려고 했는데 실패
  • Todo.id(타입 Long) 안에 withUser라는 필드가 있다고 착각
  • 해결: findById로 변경 > Service 로직도 같이 변경
    @EntityGraph(attributePaths = {"user"})
    Optional<Todo> findById(Long todoId);
    @Transactional(readOnly = true)
    public TodoResponse getTodo(long todoId) {
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

 

Lv 4. 테스트 코드 연습

1. matches 테스트

 

1) 문제 코드

    @Test
    void matches_메서드가_정상적으로_동작한다() {
        // given
        String rawPassword = "testPassword";
        String encodedPassword = passwordEncoder.encode(rawPassword);

        // when
        boolean matches = passwordEncoder.matches(encodedPassword, rawPassword);

        // then
        assertTrue(matches);
    }

 

  • @Test: 테스트 코드 선언
  • 원래 비밀번호 = testPassword
  • encodedPassword: 비밀번호 암호화
예시)
testPassword
↓
$2a$10$asdasd123123asdasd
  • boolean matches: 비밀번호 비교(암호화된 비밀번호, 원래 비밀번호)
  • matches 결과가 true여야 테스트 성공

2) 문제

        // when
        boolean matches = passwordEncoder.matches(encodedPassword, rawPassword);
  • 순서가 잘못됨

3) 해결

   // when
        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
  • 기존: 암호화 비밀번호, 원래 비밀번호
  • 변경: 원래 비밀번호, 암호화 비밀번호 

2. managet 목록 조회 테스트

 

1) 문제 코드

    @Test
    public void manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() {
        // given
        
        // 1번 Todo를 조회하는 상황
        long todoId = 1L;
        
        // “todoId=1로 조회했는데 Todo가 없는 상황”을 테스트에서 강제로 만듬
        given(todoRepository.findById(todoId)).willReturn(Optional.empty());

        // when & then
        // managerService.getManagers(todoId)를 실행했을 때
        // InvalidRequestException 예외가 발생하는지 확인
        // 예외가 터지면 exception 변수에 담음
        InvalidRequestException exception = assertThrows(InvalidRequestException.class,
                () -> managerService.getManagers(todoId));
        
        // 예외 메세지가 Manager not found 여야 통과
        assertEquals("Manager not found", exception.getMessage());
    }
  • NullPointerException (NPE): null인 객체를 사용하려고 할 때 발생하는 오류
  • InvalidRequestException(커스텀): 요청이 잘못됨 

2) 문제

  • 메서드명이 올바르지 않음
    : InvalidRequestException 예외처리를 받고 있으나 NPE 발생한다고 잘못 설명되어 있음
  • 예외 메세지가 올바르지 않음
    : Todo가 없는 경우를 발생시키는 테스트이나, 메세지는 manager이 없는 경우로 되어 있음

3) 해결

  • 메서드명 변경
    @Test
    public void manager_목록_조회_시_Todo가_없다면_InvalidRequestException_에러를_던진다() {
  • 예외 메세지 변경
assertEquals("Todo not found", exception.getMessage());

 

3. comment 등록 테스트

 

1) 문제 코드

    @Test
    public void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
        // given
        
        // 1번 Todo에 댓글 등록하려고 함
        long todoId = 1;
        
        // contents(댓글 내용)로 댓글 등록 요청 객체 생성
        CommentSaveRequest request = new CommentSaveRequest("contents");
        // 댓글을 작성하는 사용자 정보 생성
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);

	// Todo 조회가 없는 상황
        given(todoRepository.findById(anyLong())).willReturn(Optional.empty());

        // when
        // saveComment() 실행 시 ServerException이 발생
        // exception 변수에 저장
        ServerException exception = assertThrows(ServerException.class, () -> {
            commentService.saveComment(authUser, todoId, request);
        });

        // then
        // 예외 메시지가 "Todo not found" 인지 확인
        assertEquals("Todo not found", exception.getMessage());
    }
필요:class org.example.expert.domain.common.exception.ServerException
실제   :class org.example.expert.domain.common.exception.InvalidRequestException
  • InvalidRequestException: 사용자 요청이 잘못됐을 때 예외
  • ServerException: 서버 내부에서 문제가 발생했을 때 사용하는 예외

2) 문제

  • Todo 조회했을 때 할 일이 없는 경우에는 서버 오류가 아닌 잘못된 요청(존재하지 않는 데이터)
  • 그렇기 때문에 InvalidRequestException 예외처리를 하는 것이 맞음

3) 해결

        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> {
            commentService.saveComment(authUser, todoId, request);
        });
  • 기존: ServerException 예외처리
  • 변경: InvalidRequestException 예외처리
given(todoRepository.findById(anyLong())).willReturn(Optional.empty());
↓
given(todoRepository.findById(todoId)).willReturn(Optional.empty());
  • 기존: 모든 long 값 허용
  • 변경: todoId 값 정확하게 테스트

4. null 예외처리

 

1) 문제코드

    @Test
    void todo의_user가_null인_경우_예외가_발생한다() {
        // given
        
        // 요청한 사람(1번 일반 사용자)
        AuthUser authUser = new AuthUser(1L, "a@a.com", UserRole.USER);
        
        // 1번 Todo에 2번 유저를 담당자로 지정하려 함
        long todoId = 1L;
        long managerUserId = 2L;

	// Todo를 만들고 user을 null로 만듬
        Todo todo = new Todo();
        ReflectionTestUtils.setField(todo, "user", null);

	// 매니저 지정 요청 객체 생성(DTO)
        ManagerSaveRequest managerSaveRequest = new ManagerSaveRequest(managerUserId);
	// Todo가 있는 상황 가정
        given(todoRepository.findById(todoId)).willReturn(Optional.of(todo));

        // when & then
        // managerService.saveManager(authUser, todoId, managerSaveRequest)를 실행했을 때
        // InvalidRequestException이 발생하는지 확인하고, 
        // 발생한 예외를 exception 변수에 저장
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () ->
            managerService.saveManager(authUser, todoId, managerSaveRequest)
        );

        assertEquals("일정을 생성한 유저만 담당자를 지정할 수 있습니다.", exception.getMessage());
    }

 

2) 문제

  • ManagerService
if (!ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
}
  • todo.getUser() = null 인데 유저의 todo.getUser().getId()를 꺼내려고 하니 NPE 발생

3) 해결

  • getId()를 하기 전에 user가 null이 아닌지 먼저 확인
        if(todo.getUser() == null){
            throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
        }

        if (!ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
        }