SpringPlus- 개인과제(2)

ChoRong0824·2025년 3월 12일
0

Web

목록 보기
38/51
post-thumbnail

1번 문제 해결에 대해 확인하니, INSERT 쿼리가 실행되지 않는 문제가 발생함.
-> @Transactional(readOnly = true)로 설정해둬서 모든 메서드가 읽기 전용으로 되는 것이 문제였음.
즉,

  • @Transactional(readOnly = true)는 해당 클래스의 모든 메서드가 읽기 전용 모드가 됨.
  • 따라서 saveTodo()에서 INSERT 쿼리를 실행하려고 하면 에러 발생.
  • saveTodo() 같은 데이터를 변경하는 메서드는 @Transactional을 별도로 지정해야 함.
  • saveTodo()에 @Transactional 추가
@Transactional // 메서드에 트랜잭션을 활성화하여 INSERT 실행 가능
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
  • saveTodo() 메서드는 DB에 데이터를 INSERT하는 작업이므로 쓰기 트랜잭션이 필요함.
  • @Transactional(readOnly = true)가 적용된 클래스 내에서도 개별 메서드에 @Transactional을 붙이면 쓰기 가능.

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

import org.example.expert.client.WeatherClient;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.todo.dto.request.TodoSaveRequest;
import org.example.expert.domain.todo.dto.response.TodoSaveResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.todo.repository.TodoRepository;
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

/*
현재 기준에서, @SpringBootTest + @DataJpaTest + Testcontainers
방식이 가장 많이 쓰이는 것 같아서 해당 방식으로 line 별로 테스트 코드 구현.
 */
@SpringBootTest
@Transactional
public class TodoServiceTest {

    private TodoService todoService;

    @MockitoBean
    private TodoRepository todoRepository;

    @MockitoBean
    private WeatherClient weatherClient;

    @BeforeEach
    void setUp() {
        todoService = new TodoService(todoRepository, weatherClient);
    }

    @Test
    @DisplayName("할 일 저장 시 User 반환, 날씨 정보, todo 새애성, db 저장을 정상적으로 수행해야 합니다.")
    void saveTodo_성공() {
        //given
        AuthUser authUser = new AuthUser(1L, "test@user.com", UserRole.USER);
        TodoSaveRequest request = new TodoSaveRequest("할 일 title", "할 일 내용");
        String expectedWeather = "맑음";

        when(weatherClient.getTodayWeather()).thenReturn(expectedWeather);
        when(todoRepository.save(any(Todo.class))).thenAnswer(
                invocation -> {
                    Todo todo = invocation.getArgument(0);
                    return new Todo(todo.getTitle(), todo.getContents(), todo.getWeather(), todo.getUser());
                }
        );

        //when
        TodoSaveResponse response = todoService.saveTodo(authUser, request);

        //then
        //AuthUser → User 변환 확인
        User expectedUser = User.fromAuthUser(authUser);
        assertThat(response.getUser()).isEqualTo(new UserResponse(expectedUser.getId(), expectedUser.getEmail()));

        //날씨 정보 가져오는거 확인
        verify(weatherClient, times(1)).getTodayWeather();

        //투두 생성 확인
        ArgumentCaptor<Todo> todoCaptor = ArgumentCaptor.forClass(Todo.class);
        verify(todoRepository, times(1)).save(todoCaptor.capture());
        Todo savedTodo = todoCaptor.getValue();

        assertThat(savedTodo.getTitle()).isEqualTo(request.getTitle());
        assertThat(savedTodo.getContents()).isEqualTo(request.getContents());
        assertThat(savedTodo.getWeather()).isEqualTo(expectedWeather);
        assertThat(savedTodo.getUser()).isEqualTo(expectedUser);

        //응답 객체 검증
        assertThat(response.getId()).isNotNull();
        assertThat(response.getTitle()).isEqualTo(request.getTitle());
        assertThat(response.getContents()).isEqualTo(request.getContents());
        assertThat(response.getWeather()).isEqualTo(expectedWeather);


    }
}

이렇게 테스트 코드를 짜봣는데,


> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test
---- IntelliJ IDEA coverage runner ---- 
sampling ...
include patterns:
exclude patterns:
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
> Task :test
19:06:56.178 [Test worker] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [org.example.expert.domain.todo.service.TodoServiceTest]: TodoServiceTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
[2025.03.12 19:06:56] (Coverage): Error during class instrumentation: org.springframework.http.client.ReactorResourceFactory: java.lang.RuntimeException: java.io.IOException: Class reactor/netty/resources/ConnectionProvider not found
19:06:56.316 [Test worker] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration org.example.expert.ExpertApplication for test class org.example.expert.domain.todo.service.TodoServiceTest
[2025.03.12 19:06:56] (Coverage): Error during class instrumentation: org.springframework.boot.logging.log4j2.Log4J2LoggingSystem: java.lang.RuntimeException: java.io.IOException: Class org/apache/logging/log4j/core/config/composite/CompositeConfiguration not found

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v3.4.3)

2025-03-12T19:06:56.679+09:00  INFO 93762 --- [foodduck] [    Test worker] o.e.e.d.todo.service.TodoServiceTest     : Starting TodoServiceTest using Java 17.0.14 with PID 93762 (started by mun in /Users/mun/Desktop/2025/spring-plus)
2025-03-12T19:06:56.680+09:00  INFO 93762 --- [foodduck] [    Test worker] o.e.e.d.todo.service.TodoServiceTest     : No active profile set, falling back to 1 default profile: "default"
2025-03-12T19:06:57.351+09:00  INFO 93762 --- [foodduck] [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2025-03-12T19:06:57.410+09:00  INFO 93762 --- [foodduck] [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 49 ms. Found 4 JPA repository interfaces.
2025-03-12T19:06:58.859+09:00  INFO 93762 --- [foodduck] [    Test worker] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
2025-03-12T19:06:58.949+09:00  INFO 93762 --- [foodduck] [    Test worker] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 6.6.8.Final
2025-03-12T19:06:59.003+09:00  INFO 93762 --- [foodduck] [    Test worker] o.h.c.internal.RegionFactoryInitiator    : HHH000026: Second-level cache disabled
2025-03-12T19:06:59.219+09:00  INFO 93762 --- [foodduck] [    Test worker] o.s.o.j.p.SpringPersistenceUnitInfo      : No LoadTimeWeaver setup: ignoring JPA class transformer
2025-03-12T19:06:59.272+09:00  INFO 93762 --- [foodduck] [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2025-03-12T19:06:59.647+09:00  INFO 93762 --- [foodduck] [    Test worker] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@5f14590c
2025-03-12T19:06:59.649+09:00  INFO 93762 --- [foodduck] [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2025-03-12T19:06:59.718+09:00  WARN 93762 --- [foodduck] [    Test worker] org.hibernate.orm.deprecation            : HHH90000025: MySQLDialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default)
2025-03-12T19:06:59.738+09:00  INFO 93762 --- [foodduck] [    Test worker] org.hibernate.orm.connections.pooling    : HHH10001005: Database info:
	Database JDBC URL [Connecting through datasource 'HikariDataSource (HikariPool-1)']
	Database driver: undefined/unknown
	Database version: 8.0.41
	Autocommit mode: undefined/unknown
	Isolation level: undefined/unknown
	Minimum pool size: undefined/unknown
	Maximum pool size: undefined/unknown
2025-03-12T19:07:00.676+09:00  INFO 93762 --- [foodduck] [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Hibernate: 
    alter table comments 
       drop 
       foreign key FKhq2jvyd0htxaj4avgceuigt4c
Hibernate: 
    alter table comments 
       drop 
       foreign key FK8omq0tc18jd43bu5tjh6jvraq
Hibernate: 
    alter table managers 
       drop 
       foreign key FKhniowui3ft3l9sdaijwv18id
Hibernate: 
    alter table managers 
       drop 
       foreign key FKsp1db43yf1nqhswrpbwmlnhb9
Hibernate: 
    alter table todos 
       drop 
       foreign key FK9605g76a1dggbvs18f2r80gvu
Hibernate: 
    drop table if exists comments
Hibernate: 
    drop table if exists managers
Hibernate: 
    drop table if exists todos
Hibernate: 
    drop table if exists users
Hibernate: 
    create table comments (
        created_at datetime(6),
        id bigint not null auto_increment,
        modified_at datetime(6),
        todo_id bigint not null,
        user_id bigint not null,
        contents varchar(255),
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table managers (
        id bigint not null auto_increment,
        todo_id bigint not null,
        user_id bigint not null,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table todos (
        created_at datetime(6),
        id bigint not null auto_increment,
        modified_at datetime(6),
        user_id bigint not null,
        contents varchar(255),
        title varchar(255),
        weather varchar(255),
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table users (
        created_at datetime(6),
        id bigint not null auto_increment,
        modified_at datetime(6),
        email varchar(255),
        password varchar(255),
        user_role enum ('ADMIN','USER'),
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    alter table users 
       add constraint UK6dotkott2kjsp8vw4d0m25fb7 unique (email)
Hibernate: 
    alter table comments 
       add constraint FKhq2jvyd0htxaj4avgceuigt4c 
       foreign key (todo_id) 
       references todos (id)
Hibernate: 
    alter table comments 
       add constraint FK8omq0tc18jd43bu5tjh6jvraq 
       foreign key (user_id) 
       references users (id)
Hibernate: 
    alter table managers 
       add constraint FKhniowui3ft3l9sdaijwv18id 
       foreign key (todo_id) 
       references todos (id)
Hibernate: 
    alter table managers 
       add constraint FKsp1db43yf1nqhswrpbwmlnhb9 
       foreign key (user_id) 
       references users (id)
Hibernate: 
    alter table todos 
       add constraint FK9605g76a1dggbvs18f2r80gvu 
       foreign key (user_id) 
       references users (id)
2025-03-12T19:07:00.939+09:00  INFO 93762 --- [foodduck] [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2025-03-12T19:07:01.441+09:00  INFO 93762 --- [foodduck] [    Test worker] o.s.d.j.r.query.QueryEnhancerFactory     : Hibernate is in classpath; If applicable, HQL parser will be used.
2025-03-12T19:07:02.428+09:00  WARN 93762 --- [foodduck] [    Test worker] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
[2025.03.12 19:07:02] (Coverage): Error during class instrumentation: org.springframework.web.method.support.InvocableHandlerMethod: java.lang.RuntimeException: java.io.IOException: Class reactor/core/publisher/Mono not found
[2025.03.12 19:07:02] (Coverage): Error during class instrumentation: org.springframework.core.ReactiveAdapterRegistry$ReactorAdapter: java.lang.RuntimeException: java.io.IOException: Class reactor/core/publisher/Flux not found
[2025.03.12 19:07:02] (Coverage): Error during class instrumentation: org.springframework.boot.http.client.JettyClientHttpRequestFactoryBuilder: java.lang.RuntimeException: java.io.IOException: Class org/eclipse/jetty/client/transport/HttpClientTransportDynamic not found
[2025.03.12 19:07:02] (Coverage): Error during class instrumentation: org.springframework.boot.http.client.HttpComponentsClientHttpRequestFactoryBuilder: java.lang.RuntimeException: java.io.IOException: Class org/apache/hc/client5/http/protocol/RedirectStrategy not found
2025-03-12T19:07:02.810+09:00  INFO 93762 --- [foodduck] [    Test worker] o.e.e.d.todo.service.TodoServiceTest     : Started TodoServiceTest in 6.371 seconds (process running for 7.396)


expected: org.example.expert.domain.user.dto.response.UserResponse@4ce6f39e
 but was: org.example.expert.domain.user.dto.response.UserResponse@266e0341
org.opentest4j.AssertionFailedError: 
expected: org.example.expert.domain.user.dto.response.UserResponse@4ce6f39e
 but was: org.example.expert.domain.user.dto.response.UserResponse@266e0341
	at app//org.example.expert.domain.todo.service.TodoServiceTest.saveTodo_성공(TodoServiceTest.java:68)
	at java.base@17.0.14/java.lang.reflect.Method.invoke(Method.java:569)
	at java.base@17.0.14/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base@17.0.14/java.util.ArrayList.forEach(ArrayList.java:1511)


Class transformation time: 2.561847593s for 14813 classes or 1.72945898400054E-4s per class
2025-03-12T19:07:03.015+09:00  INFO 93762 --- [foodduck] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2025-03-12T19:07:03.016+09:00  INFO 93762 --- [foodduck] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2025-03-12T19:07:03.029+09:00  INFO 93762 --- [foodduck] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
> Task :test FAILED
TodoServiceTest > 할 일 저장 시 User 반환, 날씨 정보, todo 새애성, db 저장을 정상적으로 수행해야 합니다. FAILED
    org.opentest4j.AssertionFailedError at TodoServiceTest.java:68
1 test completed, 1 failed
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///Users/mun/Desktop/2025/spring-plus/build/reports/tests/test/index.html
* Try:
> Run with --scan to get full insights.
BUILD FAILED in 8s
4 actionable tasks: 1 executed, 3 up-to-date

허허..

축약하자면,
expected: UserResponse@xxxx but was: UserResponse@yyyy
테스트 실행 중 UserResponse 객체를 비교하는 부분에서 객체 주소값이 다르다는 AssertionFailedError가 발생.

expected: org.example.expert.domain.user.dto.response.UserResponse@4ce6f39e
 but was: org.example.expert.domain.user.dto.response.UserResponse@266e0341

👉 두 객체가 동일하지 않다는 오류.
즉, UserResponse 객체가 서로 다르게 인식되고 있음.

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

import org.example.expert.client.WeatherClient;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.todo.dto.request.TodoSaveRequest;
import org.example.expert.domain.todo.dto.response.TodoSaveResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.todo.repository.TodoRepository;
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

/*
현재 기준에서, @SpringBootTest + @DataJpaTest + Testcontainers
방식이 가장 많이 쓰이는 것 같아서 해당 방식으로 line 별로 테스트 코드 구현.
 */
@SpringBootTest
@Transactional
public class TodoServiceTest {

    private TodoService todoService;

    @MockitoBean
    private TodoRepository todoRepository;

    @MockitoBean
    private WeatherClient weatherClient;

    @BeforeEach
    void setUp() {
        todoService = new TodoService(todoRepository, weatherClient);
    }

    @Test
    @DisplayName("할 일 저장 시 User 반환, 날씨 정보, todo 생성, db 저장을 정상적으로 수행해야 합니다.")
    void saveTodo_성공() {
        // given
        AuthUser authUser = new AuthUser(1L, "test@user.com", UserRole.USER);
        TodoSaveRequest request = new TodoSaveRequest("할 일 title", "할 일 내용");
        String expectedWeather = "맑음";

        // Mock 설정
        when(weatherClient.getTodayWeather()).thenReturn(expectedWeather);
        when(todoRepository.save(any(Todo.class))).thenAnswer(invocation -> {
            Todo todo = invocation.getArgument(0);
            //ID 포함된 todo 객체 반환
            Todo savedTodo = new Todo(todo.getTitle(), todo.getContents(), todo.getWeather(), todo.getUser());
            return setTodoId(savedTodo, 1L);
        });

        // when
        TodoSaveResponse response = todoService.saveTodo(authUser, request);

        // then
        User expectedUser = User.fromAuthUser(authUser);

        //필드 값만 비교 (객체 주소 무시)
        assertThat(response.getUser())
                .usingRecursiveComparison()
                .isEqualTo(new UserResponse(expectedUser.getId(), expectedUser.getEmail()));

        // 날씨 정보 가져오는 거 검증
        verify(weatherClient, times(1)).getTodayWeather();

        // 투두 생성 확인
        ArgumentCaptor<Todo> todoCaptor = ArgumentCaptor.forClass(Todo.class);
        verify(todoRepository, times(1)).save(todoCaptor.capture());
        Todo savedTodo = todoCaptor.getValue();

        assertThat(savedTodo.getTitle()).isEqualTo(request.getTitle());
        assertThat(savedTodo.getContents()).isEqualTo(request.getContents());
        assertThat(savedTodo.getWeather()).isEqualTo(expectedWeather);
        assertThat(savedTodo.getUser())
                .usingRecursiveComparison()
                .isEqualTo(expectedUser);

        // 응답 객체 검증
        assertThat(response.getId()).isNotNull();
        assertThat(response.getTitle()).isEqualTo(request.getTitle());
        assertThat(response.getContents()).isEqualTo(request.getContents());
        assertThat(response.getWeather()).isEqualTo(expectedWeather);
    }

    // 리플렉션을 활용한 ID 강제 설정
    private Todo setTodoId(Todo todo, Long id) {
        try {
            java.lang.reflect.Field idField = Todo.class.getDeclaredField("id");
            idField.setAccessible(true);
            idField.set(todo, id);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException("ID 설정 실패", e);
        }
        return todo;
    }
}

최종 수정

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

import org.example.expert.client.WeatherClient;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.todo.dto.request.TodoSaveRequest;
import org.example.expert.domain.todo.dto.response.TodoSaveResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.todo.repository.TodoRepository;
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

/*
현재 기준에서, @SpringBootTest + @DataJpaTest + Testcontainers
방식이 가장 많이 쓰이는 것 같아서 해당 방식으로 line 별로 테스트 코드 구현.
 */
@SpringBootTest
@Transactional
public class TodoServiceTest {

    private TodoService todoService;

    @MockitoBean
    private TodoRepository todoRepository;

    @MockitoBean
    private WeatherClient weatherClient;

    @BeforeEach
    void setUp() {
        todoService = new TodoService(todoRepository, weatherClient);
    }

    @Test
    @DisplayName("할 일 저장 시 User 반환, 날씨 정보, todo 생성, db 저장을 정상적으로 수행해야 합니다.")
    void saveTodo_성공() {
        // given
        AuthUser authUser = new AuthUser(1L, "test@user.com", UserRole.USER);
        TodoSaveRequest request = new TodoSaveRequest("할 일 title", "할 일 내용");
        String expectedWeather = "맑음";

        // Mock 설정
        when(weatherClient.getTodayWeather()).thenReturn(expectedWeather);
        when(todoRepository.save(any(Todo.class))).thenAnswer(invocation -> {
            Todo todo = invocation.getArgument(0);
            //ID 포함된 todo 객체 반환
            Todo savedTodo = new Todo(todo.getTitle(), todo.getContents(), todo.getWeather(), todo.getUser());
            return setTodoId(savedTodo, 1L);
        });

        // when
        TodoSaveResponse response = todoService.saveTodo(authUser, request);

        // then
        User expectedUser = User.fromAuthUser(authUser);

        //필드 값만 비교 (객체 주소 무시)
        assertThat(response.getUser())
                .usingRecursiveComparison()
                .isEqualTo(new UserResponse(expectedUser.getId(), expectedUser.getEmail()));

        // 날씨 정보 가져오는 거 검증
        verify(weatherClient, times(1)).getTodayWeather();

        // 투두 생성 확인
        ArgumentCaptor<Todo> todoCaptor = ArgumentCaptor.forClass(Todo.class);
        verify(todoRepository, times(1)).save(todoCaptor.capture());
        Todo savedTodo = todoCaptor.getValue();

        assertThat(savedTodo.getTitle()).isEqualTo(request.getTitle());
        assertThat(savedTodo.getContents()).isEqualTo(request.getContents());
        assertThat(savedTodo.getWeather()).isEqualTo(expectedWeather);
        assertThat(savedTodo.getUser())
                .usingRecursiveComparison()
                .isEqualTo(expectedUser);

        // 응답 객체 검증
        assertThat(response.getId()).isNotNull();
        assertThat(response.getTitle()).isEqualTo(request.getTitle());
        assertThat(response.getContents()).isEqualTo(request.getContents());
        assertThat(response.getWeather()).isEqualTo(expectedWeather);
    }

    // 리플렉션을 활용한 ID 강제 설정
    private Todo setTodoId(Todo todo, Long id) {
        try {
            java.lang.reflect.Field idField = Todo.class.getDeclaredField("id");
            idField.setAccessible(true);
            idField.set(todo, id);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException("ID 설정 실패", e);
        }
        return todo;
    }
}

Jpa에서 팻치조인과 lazy와 프록시의 연관관계

Lazy Loading & Proxy

Lazy Loading

  • 엔티티를 조회할 때 실제 데이터를 즉시 가져오지 않고 필요할 때 가져오는 방식
  • @ManyToOne(fetch=FetchType.LAZY)또는 @OneToMany(fetch = FetchType.LAZY)가 기본값이다.
  • Jpa는 가짜(PROXY)객체를 먼저 반환하고, 실제 사용될 때 데이터를 가져온다.
@Entity
public class Todo {

    @Id @GeneratedValue
    private Long id;
    private String title;

    @ManyToOne(fetch = FetchType.LAZY) // Lazy 로딩 설정
    @JoinColumn(name = "user_id")
    private User user;
}
Todo todo = todoRepository.findById(1L).orElseThrow();
User user = todo.getUser(); //여기까지는 실제 DB 조회 안 됨 (Proxy 객체 반환)
System.out.println(user.getEmail()); //이 시점에서 쿼리 실행됨!
  • 주의: Lazy 로딩된 프록시 객체는 toString(), equals(), hashCode() 같은 메서드에서 초기화될 수도 있다.

JPA 프록시 (HibernateProxy)

Jpa가 Lazy Loading을 구현하는 방법

  • Hibernate는 가짜(Proxy) 객체를 만들어서 실제 객체 대신 반환한다.
  • user.getClass()하면 user$$HibernateProxy 같은 프록시 객체가 나온다.
Todo todo = todoRepository.findById(1L).orElseThrow();
User user = todo.getUser();
System.out.println(user.getClass()); 
// 실제 User가 아니라 User$$HibernateProxy (프록시 객체 반환)

프록시 객체를 초기화하는 방법 ?

  • user.getEmail() 같은 필드 접근
  • Hibernate.initialize(user);
  • user.getClass().getName() 같은 메서드는 초기화 안 됨 (프록시 그대로 유지)

fetch join (즉시 로딩 + N+1 문제 해결)

페치 조인(Fetch Join)

  • Lazy 로딩을 무시하고 한 번의 쿼리로 즉시 로딩(Eager Loading)
  • N+1 문제 해결에 사용됨

예제 (N+1 문제 발생)

public List<Todo> findAllTodos() {
    return todoRepository.findAll(); 
}
// select * from todos; (여기까지는 1번)
// 이후 각 Todo마다 Lazy로딩된 User 조회 → N번 추가 쿼리 발생!

해결 방법(fetch join 사용)

@Query("SELECT t FROM Todo t JOIN FETCH t.user")
List<Todo> findAllTodosWithUsers();
  • 한 번의 SQL로 모든 Todo와 User를 함께 가져옴!
  • User가 Lazy로딩이어도 프록시가 아니라 실제 객체로 조회됨
  • N+1 문제 해결!

정리

최적의 선택은 ?


과제 2

1. nickname Entity 추가 먼저 해주면 됩니다.

2. JwtUtil 에서 JWT 생성 메서드에 nickname 추가

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

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

    public String getNicknameFromToken(String token) {
        Claims claims = extractClaims(token);
        return claims.get("nickname", String.class);
    }

3. JwtFilter 수정

jwt에서 nickname을 추출해서 요청 속성에 추가해야합니다.
즉, jwt에서 nickname을 추출해야 하고, DoFilter()메서드에서 요청 속성(request.setAttribuyte())에 닉넴 추가. 그 후, 닉네임이 필요할 때 요청 속성으로 가져올 수 있도록 설정만 해주면 됩니다.

프론트엔드에서 request.getAttribute("nickname")으로 닉네임 접근 가능하도록 수정했으며, 닉넴을 활용할 컨트롤러에서도 요청 속성으로 쉽게 사용 가능합니다.

4. dto 수정

request 먼저 수정해주고,

회원가입 후 jwt토큰을 반환하는 구조를 갖고 있습니다.
여기에 추가해주면됩니다.

아니면 @AllargsConstructor어노테이션을 추가해도 됩니다.
이제 프론트엔드에서 닉네임을 활용 가능합니다.

5. Service 수정

1) SignupRequest에서 nickname 값을 받아와서 저장해야함.
2) User 엔티티를 저장할 때, nickname을 포함해야함.
3) jwt 생성 시 nickname을 추가해야함.
4) SignupResponse에도 nickname을 포함해야함.

회원가입 Service먼저 수정

package org.example.expert.domain.auth.service;

import lombok.RequiredArgsConstructor;
import org.example.expert.config.JwtUtil;
import org.example.expert.config.PasswordEncoder;
import org.example.expert.domain.auth.dto.request.SigninRequest;
import org.example.expert.domain.auth.dto.request.SignupRequest;
import org.example.expert.domain.auth.dto.response.SigninResponse;
import org.example.expert.domain.auth.dto.response.SignupResponse;
import org.example.expert.domain.auth.exception.AuthException;
import org.example.expert.domain.common.exception.InvalidRequestException;
import org.example.expert.domain.user.entity.User;
import org.example.expert.domain.user.enums.UserRole;
import org.example.expert.domain.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    @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,
                signupRequest.getNickname(),
                userRole
        );
        User savedUser = userRepository.save(newUser);

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

        return new SignupResponse(bearerToken, savedUser.getNickname());
    }

    public SigninResponse signin(SigninRequest signinRequest) {
        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
                () -> new InvalidRequestException("가입되지 않은 유저입니다."));

        // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
            throw new AuthException("잘못된 비밀번호입니다.");
        }

        String bearerToken = jwtUtil.createToken(user.getId(),
                user.getEmail(),
                user.getNickname(),
                user.getUserRole());

        return new SigninResponse(bearerToken, user.getNickname());
    }
}

AuthService 수정하다보면 빨간 표시가 많이 뜸. ->
수정하지 않은게 많아서 그럼. 필드를 추가했기 때문에, 생성자도 당연히 추가해줘야함. 일단 SigninResponse랑 SigninRequest 수정해줌.

6. JwtUtil 수정


매개변수 순서 수정.

7. jwtfilter 수정

  1. JWT에서 nickname을 찾지 못할 경우 처리 추가
    • nickname == null이면 BAD_REQUEST 응답 반환
    • 이유 ? : nickname이 반드시 존재해야 하는 필수 값이므로, 없으면 요청 자체가 잘못된 것
  2. httpRequest.setAttribute 부분 정리
    • nickname, userRole을 가져올 때 명확한 변수 사용
    • 이유 ? : 직접 claims.get()을 호출하는 대신, JwtUtil을 통해 가져오면 코드 일관성이 유지됨


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

3개의 댓글

comment-user-thumbnail
2025년 3월 13일

yo, nice man

1개의 답글