SpringPlus-개인과제

ChoRong0824·2025년 3월 12일
0

Web

목록 보기
37/51
post-thumbnail

과제를 시작하기에 앞서 git clone 했더니 err 발생.
-> 이 오류는 Spring Boot Gradle Plugin 3.3.3을 사용할 때 Gradle 버전과 호환되지 않는 문제에서 발생한 것

  1. Gradle 버전 문제
    • org.gradle.plugin.api-version이 7.4를 요구하지만, 현재 사용 가능한 spring-boot-gradle-plugin:3.3.3은 Gradle 7.4와 호환되지 않음.
    • spring-boot-gradle-plugin:3.3.3은 Gradle 7.5 이상 또는 8.x 버전이 필요.
  2. Gradle 버전 확인 및 업그레이드 필요
    • Gradle 7.4를 사용 중이라면 gradle-wrapper.properties에서 버전을 확인하고 7.5 이상으로 업그레이드해야 합니다.

확인해보면, 현재 Gradle 버전은 8.12.1이며, 최신 버전이므로 Gradle 버전 문제는 아닌 것으로 확인됩니다. 그렇다면!? Spring Boot Gradle Plugin 3.3.3이 Gradle과 충돌하는 것 같습니다.

  • Gradle Wrapper 다시 생성 (손실된 파일 복구)
    현재 Gradle Wrapper 파일이 손실된 상태이므로 다시 생성해야 합니다.
    gradle wrapper --gradle-version 8.12.1
    이 명령어를 실행하면 gradle/wrapper/ 폴더와 gradle-wrapper.properties가 자동 생성됩니다.

  • Gradle 캐시 정리 및 재빌드

rm -rf ~/.gradle/caches
./gradlew --stop
./gradlew clean build --refresh-dependencies

  • run하는게 사라졌고, 실행이 안되는중.


sdk default로 되어있었는데, 이 부분을 지정해주면 됩니다.


실행시 에러 발생

properties파일이 없네요.
추가해주고 실행하도록 하겠습니다. (얌파일도 가능)

2025-03-12T09:44:29.073+09:00 ERROR 89461 --- [           main] o.s.boot.SpringApplication               : Application run failed

org.springframework.context.ApplicationContextException: Unable to start web server
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:170) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) ~[spring-context-6.2.3.jar:6.2.3]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1361) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1350) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.example.expert.ExpertApplication.main(ExpertApplication.java:14) ~[main/:na]
Caused by: org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat
	at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:147) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.<init>(TomcatWebServer.java:107) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:520) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:222) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:193) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) ~[spring-boot-3.4.3.jar:3.4.3]
	... 8 common frames omitted
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'filterConfig' defined in file [/Users/mun/Desktop/2025/spring-plus/build/classes/java/main/org/example/expert/config/FilterConfig.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1381) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1218) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:346) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1361) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1191) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:346) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:207) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:211) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:202) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addServletContextInitializerBeans(ServletContextInitializerBeans.java:97) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.ServletContextInitializerBeans.<init>(ServletContextInitializerBeans.java:86) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.getServletContextInitializerBeans(ServletWebServerApplicationContext.java:271) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.selfInitialize(ServletWebServerApplicationContext.java:245) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.springframework.boot.web.embedded.tomcat.TomcatStarter.onStartup(TomcatStarter.java:52) ~[spring-boot-3.4.3.jar:3.4.3]
	at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4467) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1203) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1193) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) ~[na:na]
	at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:75) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) ~[na:na]
	at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:749) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:772) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1203) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1193) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) ~[na:na]
	at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:75) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) ~[na:na]
	at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:749) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:203) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.StandardService.startInternal(StandardService.java:415) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:870) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.apache.catalina.startup.Tomcat.start(Tomcat.java:437) ~[tomcat-embed-core-10.1.36.jar:10.1.36]
	at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:128) ~[spring-boot-3.4.3.jar:3.4.3]
	... 13 common frames omitted
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:515) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1445) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:346) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1606) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1552) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) ~[spring-beans-6.2.3.jar:6.2.3]
	... 62 common frames omitted
Caused by: org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'jwt.secret.key' in value "${jwt.secret.key}"
	at org.springframework.util.PlaceholderResolutionException.withValue(PlaceholderResolutionException.java:81) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.util.PlaceholderParser$ParsedValue.resolve(PlaceholderParser.java:423) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.util.PlaceholderParser.replacePlaceholders(PlaceholderParser.java:128) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:118) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:114) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.core.env.AbstractPropertyResolver.doResolvePlaceholders(AbstractPropertyResolver.java:255) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.core.env.AbstractPropertyResolver.resolveRequiredPlaceholders(AbstractPropertyResolver.java:226) ~[spring-core-6.2.3.jar:6.2.3]
	at org.springframework.context.support.PropertySourcesPlaceholderConfigurer.lambda$processProperties$0(PropertySourcesPlaceholderConfigurer.java:201) ~[spring-context-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.AbstractBeanFactory.resolveEmbeddedValue(AbstractBeanFactory.java:971) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1574) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1552) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:785) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:768) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146) ~[spring-beans-6.2.3.jar:6.2.3]
	at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:509) ~[spring-beans-6.2.3.jar:6.2.3]
	... 73 common frames omitted


Process finished with exit code 1

읽어보니,

Caused by: org.springframework.util.PlaceholderResolutionException:
Could not resolve placeholder 'jwt.secret.key' in value "${jwt.secret.key}"

이게 원인인 것 같습니다.
이는 application.properties에서 jwt.secret.key가 정의되지 않아서 오류가 발생한 것입니다. 설정만 해주면 됩니다.

코드의 어노테이션을 확인해보니, jwt.secret으로 되어있는 것이 아니라,
jwt.secret.key로 되어있으니 properties 수정해주면 됩니다.


이제 정상 실행됨을 확인할 수 있습니다.
이제 개인 과제를 시작해보겠습니다!


1. 코드 개선 퀴즈 transactional

일단, @Transactional이 무엇인지 확실하게 설명할 정도로 이해하는 것이 중요하다고 판단했습니다.

또한, 어!? ㅋㅋ 재밌을 것 같아서 테스트 코드로 짜보면서 수정해보려 합니다.
서비스만 테스트 코드 짜봐서 심심했는데 controller도 이번에 테스트코드로 짜봐야겠습니다!! 바로 진행.

테스트 코드 구현

package org.example.expert.domain.todo.controller;

import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.common.exception.InvalidRequestException;
import org.example.expert.domain.todo.dto.response.TodoResponse;
import org.example.expert.domain.todo.service.TodoService;
import org.example.expert.domain.user.dto.response.UserResponse;
import org.example.expert.domain.user.entity.User;
import org.example.expert.domain.user.enums.UserRole;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(TodoController.class)
class TodoControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private TodoService todoService;

    @Test
    void todo_단건_조회에_성공한다() throws Exception {
        // given
        long todoId = 1L;
        String title = "title";
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
        User user = User.fromAuthUser(authUser);
        UserResponse userResponse = new UserResponse(user.getId(), user.getEmail());
        TodoResponse response = new TodoResponse(
                todoId,
                title,
                "contents",
                "Sunny",
                userResponse,
                LocalDateTime.now(),
                LocalDateTime.now()
        );

        // when
        when(todoService.getTodo(todoId)).thenReturn(response);

        // then
        mockMvc.perform(get("/todos/{todoId}", todoId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(todoId))
                .andExpect(jsonPath("$.title").value(title));
    }

    @Test
    void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
        // given
        long todoId = 1L;

        // when
        when(todoService.getTodo(todoId))
                .thenThrow(new InvalidRequestException("Todo not found"));

        // then
        mockMvc.perform(get("/todos/{todoId}", todoId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
                .andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
                .andExpect(jsonPath("$.message").value("Todo not found"));
    }
}

이렇게 구현되어 있었다.
todo단건조회시_todo가존재하지않아예외가_발생한다()
를 실행하니,

mockMvc.perform(get("/todos/{todoId}", todoId))
        .andExpect(status().isOk()) //여기서 200 OK를 기대하고 있는 것은 잘못된 방식이다.
        .andExpect(jsonPath("$.status").value(HttpStatus.OK.name())) //OK를 기대하고 있어서 문제가 발생.
        .andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
        .andExpect(jsonPath("$.message").value("Todo not found"));

보통 리소스가 없으면 HTTP 404 Not Found 를 반환해야하는데, 에러 발생에 200ok를 기대하고 있는 코드가 있어서 문제가 발생하는 것입니다.

  // then
        mockMvc.perform(get("/todos/{todoId}", todoId))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
                .andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
                .andExpect(jsonPath("$.message").value("Todo not found"));
    }

코드를 수정하고 실행했더니.

@MockBean in org.springframework.boot.test.mock.mockito has been deprecated and marked for removal

@MockBean이 Spring Boot 3.4.x 이후로 Deprecated 되었기떄문에,
해결 방법은 @MockBean 대신 @ExtendWith(MockitoExtension.class) + @Mock 사용하는 것입니다.


Failed to load ApplicationContext for [WebMergedContextConfiguration@852ef8d testClass = org.example.expert.domain.todo.controller.TodoControllerTest, locations = [], classes = [org.example.expert.ExpertApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@68ead359, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@424ebba3, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@da366df8, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@fe56ef90, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@2371aaca, [ImportsContextCustomizer@147e0734 key = [org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration, org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration, org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration, org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration, org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration, org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration, org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration, org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration, org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration, org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration, org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@655f7ea, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@17a87e37, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory$DisableReactorResourceFactoryGlobalResourcesContextCustomizerCustomizer@686449f9, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@38f93de4], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@852ef8d testClass = org.example.expert.domain.todo.controller.TodoControllerTest, locations = [], classes = [org.example.expert.ExpertApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@68ead359, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@424ebba3, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@da366df8, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@fe56ef90, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@2371aaca, [ImportsContextCustomizer@147e0734 key = [org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration, org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration, org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration, org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration, org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration, org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration, org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration, org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration, org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration, org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration, org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration, org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@655f7ea, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@17a87e37, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory$DisableReactorResourceFactoryGlobalResourcesContextCustomizerCustomizer@686449f9, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@38f93de4], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:180)
	at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:130)
	at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:155)
	at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:111)
	at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:260)
	at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:160)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:179)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1625)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at java.base/java.util.Optional.orElseGet(Optional.java:364)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'todoController' defined in file [/Users/mun/Desktop/2025/spring-plus/build/classes/java/main/org/example/expert/domain/todo/controller/TodoController.class]: Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'org.example.expert.domain.todo.service.TodoService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
	at app//org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804)
	at app//org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
	at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1381)
	at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1218)
	at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563)
	at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523)
	at app//org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339)
	at app//org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:346)
	at app//org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337)
	at app//org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.instantiateSingleton(DefaultListableBeanFactory.java:1155)
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingleton(DefaultListableBeanFactory.java:1121)
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:1056)
	at app//org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:987)
	at app//org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:627)
	at app//org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752)
	at app//org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439)
	at app//org.springframework.boot.SpringApplication.run(SpringApplication.java:318)
	at app//org.springframework.boot.test.context.SpringBootContextLoader.lambda$loadContext$3(SpringBootContextLoader.java:137)
	at app//org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:58)
	at app//org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:46)
	at app//org.springframework.boot.SpringApplication.withHook(SpringApplication.java:1461)
	at app//org.springframework.boot.test.context.SpringBootContextLoader$ContextLoaderHook.run(SpringBootContextLoader.java:553)
	at app//org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:137)
	at app//org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:108)
	at app//org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:225)
	at app//org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:152)
	... 19 more
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.example.expert.domain.todo.service.TodoService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2177)
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1627)
	at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1552)
	at app//org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913)
	at app//org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791)
	... 45 more

현재 발생한 오류는 TodoService의 @Mock이 @WebMvcTest와 함께 정상적으로 주입되지 않은 문제입니다.
즉, TodoController에서 TodoService를 주입받는데, @WebMvcTest가 TodoService를 빈으로 생성하지 않아서 발생한 문제입니다.
이를 해결하려면 원인분석을 제대로해야합니다.

Error creating bean with name 'todoController' [...] No qualifying bean of type 'org.example.expert.domain.todo.service.TodoService' available
  • @WebMvcTest는 컨트롤러 계층만 로드하고, 서비스 빈(Service Bean)은 로드하지 않았다. 하지만 TodoController는 TodoService를 의존성으로 받았다.
    따라서, TodoService를 Mock으로 등록해줘야 한다

해결 방법 (올바른 @MockBean 사용)

    @Test
    void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
        // given
        long todoId = 1L;

        // when
        when(todoService.getTodo(todoId))
                .thenThrow(new InvalidRequestException("Todo not found"));

        // then
        mockMvc.perform(get("/todos/{todoId}", todoId))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(HttpStatus.NOT_FOUND.name()))
                .andExpect(jsonPath("$.code").value(HttpStatus.NOT_FOUND.value()))
                .andExpect(jsonPath("$.message").value("Todo not found"));
    }
// 컨트롤러에 추가했음.
/*
    404 에러 설정
     */
    @ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidRequestException(InvalidRequestException ex) {
        Map<String, Object> response = Map.of(
                "status", HttpStatus.NOT_FOUND.name(),
                "code", HttpStatus.NOT_FOUND.value(),
                "message", ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
package org.example.expert.domain.common.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND) //기본 상태 코드 404
public class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String message) {
        super(message);
    }
}

최종적으로 통과한 테스트 코드와 처음의 테스트 코드를 비교하면 다음과 같은 차이점이 존재합니다.

1. @MockBean 대신 @MockitoBean 사용 문제 해결

처음 코드의 문제점은 무엇 ?

@MockitoBean
private TodoService todoService;

@MockitoBean은 존재하지 않는 애너테이션이며,
Spring Boot 3.4.x 이후에도 Spring은 @MockBean을 사용해야 한다.

@MockBean을 사용해야 @WebMvcTest에서 Mock된 Service 객체를 컨텍스트에 등록할 수 있음.

@MockBean
private TodoService todoService;

2. 예외 발생 시 HTTP 응답 코드가 400에서 404로 변경됨

처음 코드의 문제점
Status expected:<404> but was:<400>
InvalidRequestException 발생 시, Spring의 기본 처리 방식으로 인해 400 (BAD_REQUEST) 응답이 반환됩니다.
하지만 제가 원하는 것은 404 (NOT_FOUND) 응답을 해야합니다.

해결 방법 1

(InvalidRequestException에 @ResponseStatus 추가)

@ResponseStatus(HttpStatus.NOT_FOUND) // 기본 상태 코드 404
public class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String message) {
        super(message);
    }
}

이 코드를 추가하면, InvalidRequestException이 발생할 때 Spring이 자동으로 404를 반환하게 됨.

해결 방법 2

(컨트롤러에서 @ExceptionHandler로 예외 처리)

@ExceptionHandler(InvalidRequestException.class)
public ResponseEntity<Map<String, Object>> handleInvalidRequestException(InvalidRequestException ex) {
    Map<String, Object> response = Map.of(
        "status", HttpStatus.NOT_FOUND.name(),
        "code", HttpStatus.NOT_FOUND.value(),
        "message", ex.getMessage()
    );
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}

InvalidRequestException이 발생하면 명확하게 JSON 응답을 지정할 수 있게 됩니다.
만약, @ResponseStatus 없이 예외 처리를 하고 싶다면 이 방법을 사용해야 함.

해결 3. JSON 응답 데이터의 status 필드 수정

처음 코드의 문제점
.andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))//잘못됨
하지만 예외 발생 시 status 값은 "OK"가 아니라 "NOT_FOUND"가 되어야 함.

.andExpect(jsonPath("$.status").value(HttpStatus.NOT_FOUND.name())) // 수정
.andExpect(jsonPath("$.code").value(HttpStatus.NOT_FOUND.value())) // 404 코드 반환

블로그를 찾아보니 이런 식으로 수정하는 것이 좋다고 했는데, 문득, 궁금증이 생겼다. 왜 이런 식으로 해결해야만 하는 것이지 ?
문제를 해결할 때는 Spring Boot의 동작 방식과 HTTP 상태 코드의 의미를 고려해야 했기 때문입니다. 해결한 방법 정리 뒤에 바로 정리해두겠습니다.

@MockBean을 사용한 이유

  • @WebMvcTest는 컨트롤러만 로드하고 서비스 계층은 로드하지 않음.
  • TodoController는 TodoService를 필요로 하므로, Mock된 TodoService가 주입되어야 함.
  • @MockBean을 사용하면 TodoService의 Mock 객체가 Spring 컨텍스트에 등록되어, 컨트롤러가 의존성을 주입받을 수 있음.

@ResponseStatus(HttpStatus.NOT_FOUND)를 사용한 이유

  • InvalidRequestException이 발생할 때 자동으로 404 상태 코드를 반환하려면 @ResponseStatus를 사용하면 편리함.
  • Spring Boot는 @ResponseStatus가 지정된 예외가 발생하면, 해당 상태 코드와 함께 기본 JSON 응답을 반환함.
  • @ExceptionHandler 없이도 기본적으로 404 응답을 만들 수 있기 때문에 간단한 방법이었음.

@ExceptionHandler를 사용한 이유

  • @ResponseStatus(HttpStatus.NOT_FOUND)는 기본적인 상태 코드만 설정할 수 있음.
  • 하지만 API에서 JSON 응답 형식을 통일해야 하는 경우, @ExceptionHandler를 사용해야 함.
  • 예를 들어, 예외 응답을 다음과 같은 JSON 형태로 통일하고 싶다면
{
  "status": "NOT_FOUND",
  "code": 404,
  "message": "Todo not found"
}

→ 이걸 하려면 @ExceptionHandler를 직접 구현해야 함.

문득 또 궁금증이 생겼씁니다.

정말로, 이런 해결 방법밖에 없었나 ??

C(hat GPT) 다른 해결 방법

방법 1️⃣: @ControllerAdvice를 사용해서 전역 예외 처리

@ExceptionHandler를 컨트롤러마다 만들지 않고, 전역적으로 관리할 수도 있었음.
장점: 코드 중복이 줄어듦.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidRequestException(InvalidRequestException ex) {
        Map<String, Object> response = Map.of(
            "status", HttpStatus.NOT_FOUND.name(),
            "code", HttpStatus.NOT_FOUND.value(),
            "message", ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
}

방법 2️⃣: Spring 기본 예외 응답을 유지하는 방법

@ResponseStatus(HttpStatus.NOT_FOUND) 만 사용하면 기본적인 JSON 응답이 자동 생성됨.
JSON 응답 형식을 맞추는 것이 중요하지 않다면, @ExceptionHandler 없이도 해결 가능.


제가 생각 헀을땐, 이러한 해결 방법을 추론하기 위해 필요한 개념들을 정리해봤습니다.

1. @WebMvcTest와 @MockBean의 역할

@WebMvcTest는 Controller 계층만 로드하고, @MockBean이 없으면 서비스 계층이 주입되지 않음.
이를 모르고 @MockBean 없이 실행하면 NoSuchBeanDefinitionException 오류 발생.

2. Spring의 예외 처리 방식

Spring은 기본적으로 예외가 발생하면 @ResponseStatus에 정의된 코드로 HTTP 응답을 생성함.
@ExceptionHandler를 사용하면 JSON 응답 형식을 커스텀할 수 있음.

3. HTTP 상태 코드의 의미

400 (BAD_REQUEST): 클라이언트가 잘못된 요청을 보냈을 때 (ex: 유효성 검사 실패)
404 (NOT_FOUND): 요청한 리소스가 존재하지 않을 때
위 개념을 명확히 이해하고, 올바른 상태 코드를 반환하도록 예외를 처리해야 함.

최종 정리

변경 사항 및 이유

  • @MockitoBean → @MockBean 변경 @MockitoBean은 존재하지 않는 애너테이션이므로 @MockBean 사용
  • InvalidRequestException에 @ResponseStatus(HttpStatus.NOT_FOUND) 추가 자동으로 404 응답을 반환하도록 설정
  • @ExceptionHandler 추가 JSON 응답 형식을 통일하기 위해 사용
  • jsonPath("$.status").value(HttpStatus.OK.name()) → HttpStatus.NOT_FOUND.name() 로 수정 예외 발생 시 404 응답을 기대하는 것이 올바름

결론

테스트가 처음 실패했던 이유는 Spring Boot의 예외 처리 방식과 @WebMvcTest의 동작 원리를 완전히 이해하지 못했기 때문었씁니다. 이 문제를 해결하려면 다음 개념을 잘 알고 있어야합니다.

📌 문제 해결을 위한 필수 개념 (재정리)

  • @WebMvcTest는 컨트롤러만 로드하며, 서비스는 @MockBean으로 주입해야 함.
  • Spring의 예외 처리 방식 (@ResponseStatus, @ExceptionHandler, @ControllerAdvice)을 이해해야 함.
  • HTTP 상태 코드의 의미를 올바르게 적용해야 함.
  • 테스트 코드에서 예상하는 응답(JSON 형식, HTTP 상태 코드)을 정확하게 정의해야 함.

바보 같앗다. MockBean에 대해 다시 공부하며 정리.

Mockbean에 대해 타고 들어가보려 하니 안뜨고 MockitoBean만 타고 들어가면서 정리 되어있는 클래스를 발견했고, 이전에 튜터님의 특강이 여럼풋이 기억났다.
그래도 정리한 것들은 지우지 않을 것이다.
구글링 하면서 공부했던 흔적들이라고 생각하며, 늦지않았으며, 2025.03.12 지금 기준으로 잘못된 것에 대해 다시 정리하면 된다 생각하기 때문이다.

@MockitoBean은 Spring Boot 6.2에서 새롭게 추가된 애너테이션입니다.

아직까진, @MockBean이 기존 방식인 이유
@MockBean은 Spring Boot 1.4 (2016년)부터 사용되었으며,
Spring Boot의 테스트에서 Mock 객체를 Spring 컨텍스트에 등록하는 표준적인 방법이었음.

@MockBean
private TodoService todoService;

기본적으로 Mockito.mock(TodoService.class)을 생성하고 Spring 컨텍스트에 등록함.

  • 컨트롤러 테스트나 서비스 테스트에서 가장 일반적으로 사용됨.
  • @SpringBootTest, @WebMvcTest와 함께 사용 가능.

그렇다면, @MockitoBean은 무엇인가? (2025년 Spring Boot 6.2 도입)

Spring Boot 6.2부터 새롭게 추가된 @MockitoBean은 기존 @MockBean과 유사하지만, 더 세밀한 설정이 가능함.
제가 봤던 소스코드에 따르면

/*
 * Copyright 2002-2025 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.test.context.bean.override.mockito;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.mockito.Answers;
import org.mockito.MockSettings;

import org.springframework.core.annotation.AliasFor;
import org.springframework.test.context.bean.override.BeanOverride;

/**
 * {@code @MockitoBean} is an annotation that can be used in test classes to
 * override a bean in the test's
 * {@link org.springframework.context.ApplicationContext ApplicationContext}
 * with a Mockito mock.
 *
 * <p>{@code @MockitoBean} can be applied in the following ways.
 * <ul>
 * <li>On a non-static field in a test class or any of its superclasses.</li>
 * <li>On a non-static field in an enclosing class for a {@code @Nested} test class
 * or in any class in the type hierarchy or enclosing class hierarchy above the
 * {@code @Nested} test class.</li>
 * <li>At the type level on a test class or any superclass or implemented interface
 * in the type hierarchy above the test class.</li>
 * <li>At the type level on an enclosing class for a {@code @Nested} test class
 * or on any class or interface in the type hierarchy or enclosing class hierarchy
 * above the {@code @Nested} test class.</li>
 * </ul>
 *
 * <p>When {@code @MockitoBean} is declared on a field, the bean to mock is inferred
 * from the type of the annotated field. If multiple candidates exist in the
 * {@code ApplicationContext}, a {@code @Qualifier} annotation can be declared
 * on the field to help disambiguate. In the absence of a {@code @Qualifier}
 * annotation, the name of the annotated field will be used as a <em>fallback
 * qualifier</em>. Alternatively, you can explicitly specify a bean name to mock
 * by setting the {@link #value() value} or {@link #name() name} attribute.
 *
 * <p>When {@code @MockitoBean} is declared at the type level, the type of bean
 * (or beans) to mock must be supplied via the {@link #types() types} attribute.
 * If multiple candidates exist in the {@code ApplicationContext}, you can
 * explicitly specify a bean name to mock by setting the {@link #name() name}
 * attribute. Note, however, that the {@code types} attribute must contain a
 * single type if an explicit bean {@code name} is configured.
 *
 * <p>A bean will be created if a corresponding bean does not exist. However, if
 * you would like for the test to fail when a corresponding bean does not exist,
 * you can set the {@link #enforceOverride() enforceOverride} attribute to {@code true}
 * &mdash; for example,  {@code @MockitoBean(enforceOverride = true)}.
 *
 * <p>Dependencies that are known to the application context but are not beans
 * (such as those
 * {@linkplain org.springframework.beans.factory.config.ConfigurableListableBeanFactory#registerResolvableDependency(Class, Object)
 * registered directly}) will not be found, and a mocked bean will be added to
 * the context alongside the existing dependency.
 *
 * <p><strong>NOTE</strong>: Only <em>singleton</em> beans can be mocked.
 * Any attempt to mock a non-singleton bean will result in an exception. When
 * mocking a bean created by a {@link org.springframework.beans.factory.FactoryBean
 * FactoryBean}, the {@code FactoryBean} will be replaced with a singleton mock
 * of the type of object created by the {@code FactoryBean}.
 *
 * <p>There are no restrictions on the visibility of a {@code @MockitoBean} field.
 * Such fields can therefore be {@code public}, {@code protected}, package-private
 * (default visibility), or {@code private} depending on the needs or coding
 * practices of the project.
 *
 * <p>{@code @MockitoBean} fields and type-level {@code @MockitoBean} declarations
 * will be inherited from an enclosing test class by default. See
 * {@link org.springframework.test.context.NestedTestConfiguration @NestedTestConfiguration}
 * for details.
 *
 * <p>{@code @MockitoBean} may be used as a <em>meta-annotation</em> to create custom
 * <em>composed annotations</em> &mdash; for example, to define common mock
 * configuration in a single annotation that can be reused across a test suite.
 * {@code @MockitoBean} can also be used as a <em>{@linkplain Repeatable repeatable}</em>
 * annotation at the type level &mdash; for example, to mock several beans by
 * {@link #name() name}.
 *
 * @author Simon Baslé
 * @author Sam Brannen
 * @since 6.2
 * @see org.springframework.test.context.bean.override.mockito.MockitoBeans @MockitoBeans
 * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean
 * @see org.springframework.test.context.bean.override.convention.TestBean @TestBean
 */
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(MockitoBeans.class)
@BeanOverride(MockitoBeanOverrideProcessor.class)
public @interface MockitoBean {

	/**
	 * Alias for {@link #name() name}.
	 * <p>Intended to be used when no other attributes are needed &mdash; for
	 * example, {@code @MockitoBean("customBeanName")}.
	 * @see #name()
	 */
	@AliasFor("name")
	String value() default "";

	/**
	 * Name of the bean to mock.
	 * <p>If left unspecified, the bean to mock is selected according to the
	 * configured {@link #types() types} or the annotated field's type, taking
	 * qualifiers into account if necessary. See the {@linkplain MockitoBean
	 * class-level documentation} for details.
	 * @see #value()
	 */
	@AliasFor("value")
	String name() default "";

	/**
	 * One or more types to mock.
	 * <p>Defaults to none.
	 * <p>Each type specified will result in a mock being created and registered
	 * with the {@code ApplicationContext}.
	 * <p>Types must be omitted when the annotation is used on a field.
	 * <p>When {@code @MockitoBean} also defines a {@link #name name}, this attribute
	 * can only contain a single value.
	 * @return the types to mock
	 * @since 6.2.2
	 */
	Class<?>[] types() default {};

	/**
	 * Extra interfaces that should also be declared by the mock.
	 * <p>Defaults to none.
	 * @return any extra interfaces
	 * @see MockSettings#extraInterfaces(Class...)
	 */
	Class<?>[] extraInterfaces() default {};

	/**
	 * The {@link Answers} type to use in the mock.
	 * <p>Defaults to {@link Answers#RETURNS_DEFAULTS}.
	 * @return the answer type
	 */
	Answers answers() default Answers.RETURNS_DEFAULTS;

	/**
	 * Whether the generated mock is serializable.
	 * <p>Defaults to {@code false}.
	 * @return {@code true} if the mock is serializable
	 * @see MockSettings#serializable()
	 */
	boolean serializable() default false;

	/**
	 * The reset mode to apply to the mock.
	 * <p>The default is {@link MockReset#AFTER} meaning that mocks are
	 * automatically reset after each test method is invoked.
	 * @return the reset mode
	 */
	MockReset reset() default MockReset.AFTER;

	/**
	 * Whether to require the existence of the bean being mocked.
	 * <p>Defaults to {@code false} which means that a mock will be created if a
	 * corresponding bean does not exist.
	 * <p>Set to {@code true} to cause an exception to be thrown if a corresponding
	 * bean does not exist.
	 * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE_OR_CREATE
	 * @see org.springframework.test.context.bean.override.BeanOverrideStrategy#REPLACE
	 */
	boolean enforceOverride() default false;

}

@MockitoBean은 다음과 같은 기능을 제공합니다.

  1. 기본적으로 Mockito.mock()을 생성하여 Spring 컨텍스트에 등록
  2. 여러 개의 타입을 한 번에 Mocking 가능 (types() 속성 추가됨)
@MockitoBean(types = {TodoService.class, UserService.class})
  1. Mock 객체의 Answers 설정 가능 (RETURNS_DEFAULTS, RETURNS_SMART_NULLS, etc.)
@MockitoBean(answers = Answers.RETURNS_SMART_NULLS)
  1. Mock 객체가 반드시 기존 Bean을 대체해야 하는지 (enforceOverride) 설정 가능
@MockitoBean(enforceOverride = true)

즉, @MockitoBean은 기존 @MockBean보다 더 강력한 기능을 제공하지만,
Spring Boot 6.2에서 새롭게 추가된 애너테이션이라 기존 프로젝트에서는 @MockBean을 사용하는 게 일반적이었지만, 세팅된 프로젝트에 따라 적용하면 됩니다.

다시 정리, 2025년 3월 현재, 어떤 걸 사용해야 할까?

✅ Spring Boot 6.2 이상을 사용 중이면 @MockitoBean을 사용해도 괜찮음.
✅ Spring Boot 6.1 이하라면 @MockBean을 사용해야 함.

즉, 프로젝트가 Spring Boot 6.2 이상인지 확인한 후, @MockitoBean을 사용할지 결정하면 됩니다 !
👉 확인 방법

./gradlew dependencies | grep 'spring-boot'

또는 build.gradle에서 확인

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter:6.2.0'
}
profile
백엔드를 지향하며, 컴퓨터공학과를 졸업한 취준생입니다. 많이 부족하지만 열심히 노력해서 실력을 갈고 닦겠습니다. 부족하고 틀린 부분이 있을 수도 있지만 이쁘게 봐주시면 감사하겠습니다. 틀린 부분은 댓글 남겨주시면 제가 따로 학습 및 자료를 찾아봐서 제 것으로 만들도록 하겠습니다. 귀중한 시간 방문해주셔서 감사합니다.

0개의 댓글