SPARTA 과제/SPRING

심화) LV 7 Test code

kjw81024 2026. 3. 7. 18:48

냅다 자랑

80까지는 노가다로 올릴만 했는데 (그냥 똑같은 내용을 여러번 if 분기점에서 나눠지게 설정하면 됨!)

이후에 JwtFilter JwtUtil Interceptor 부분은 진짜 모르는 내용이라 그 때 부턴 속도가 아예 안 났다...

힘들었지만 뿌듯하니까 자랑부터 하고 / TestCode 작성 방법과 발생했던 오류들을 정리해보기


단위 테스트 작성

0

일단 Line Coverage 는 말 그대로 테스트가 코드의 Line 을 얼마나 커버하는지에 대한 의미

사진을 보면 쓸모있는 코드가 약 334 줄

그렇다고 334 개를 작성하는 건 아니고 If 문이 있다면 If 문 내부를 통과하냐 안하냐 등으로 코드가 나뉜다

어떤 라인은 성공코드를 작성하면서 자연스럽게 추가되기도 함!

 

모든 테스트 코드는

//given

//when

//then 절로 나누어서 작성되는게 좋다


1

먼저 작성하기 위해 아래 어노테이션을 추가해준다

# 테스트 코드

@ExtendWith(MockitoExtension.class)

라인커버리지는 클래스 단위로 체크하기 때문에 Controller - Service - Repository 를 넘나들며 실제로 어떻게 데이터가 가공되고 출력되는지 알 수 없다! (클래스를 넘나들며 잘 동작하는지 확인할 수 있는 건 실행 또는 통합테스트..)

 

그렇기 때문에 임시 클래스인 Mock 을 사용해서 Repository 등 필요한 클래스를 임시로 작성해준다

필요한 Mock 을 작성한 후 InjectMocks 를 이용해서 합쳐주면 된다

# 테스트 코드
# Comment Service Test 

@Mock
private CommentRepository commentRepository;
@Mock
private TodoRepository todoRepository;
@InjectMocks # (inject : 주입하다)
private CommentService commentService; # 테스트 하려는 대상

 

다시 돌아가서 Mock 을 사용할 때 꼭 @ExtendWith(MockitoExtension.class) 를 해주는 이유는 해당 어노테이션을 사용하지 않으면 @Mock 으로 설정해줘도 null 값이라고 생각하기 때문이다 

읽어보면 this.jwtUtil is null 이라고 뜨는 오류메시지를 확인할 수 있다.

어노테이션을 달지 않았을 때 오류

어노테이션을 달고 Mock 을 추가했다면 Test 어노테이션을 추가로 작성하면 된다

# 테스트 코드

@Test
@DisplayName("성공 - 댓글 생성")
public void saveComment_succeed() {
	# test 내용
}

@Test
@DisplayName("실패 - 존재하지 않는 TODO")
public void saveComment_failed_by_todo_not_found() {
	# test 내용
}

Display name 은 가독성을 위해 작성해주는 게 좋다

함수명은 마음대로 작성해도 상관 X 한글이 들어가도 됨

 

추가로 Test 를 작성할 때는 아래처럼 좌우로 테스트하려는 대상을 띄우고 진행하니 편했다


3 - 1

// given1

# 실제 코드

public CommentSaveResponse saveComment(
    AuthUser authUser, long todoId, CommentSaveRequest commentSaveRequest) {

실제 코드에서 controller 에서 전달해주는 매개변수로 authUser, todoId, commentSaveRequest 를 받아오는데 Test 는 그런 거 없으니까 테스트 코드에서 작성해줘야한다

# 테스트 코드

long todoId = 1;
CommentSaveRequest request = new CommentSaveRequest("contents");
AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
User user = User.fromAuthUser(authUser);
Todo todo = new Todo("title", "title", "contents", user);
Comment comment = new Comment(request.getContents(), user, todo);

fromAuthUser 같은 static method 는 굳이 동작을 when 이나 given 절로 지정해주지 않아도 되기 때문에 그냥 작성해도 된다

 

List 종류도 만들어줄 수 있다

# 테스트 코드

Comment comment = new Comment("테스트 내용", user, todo);
List<Comment> commentList = List.of(comment);

when(commentRepository.findByTodoIdWithUser(todoId)).thenReturn(commentList);

 

Page 종류

# 테스트 코드

# page o
Todo mockTodo = new Todo("할 일 제목","할 일 내용", "Sunny", user);
List<Todo> todoList = List.of(mockTodo);
Page<Todo> mockTodos = new PageImpl<>(todoList, pageable, todoList.size());

# empty page
Page<Todo> emptyPage = new PageImpl<>(List.of(), pageable, 0);
when(todoRepository.findAllByOrderByModifiedAtDesc(pageable)).thenReturn(emptyPage);

3 - 2

// given2

아까 말 했듯이 테스트 코드를 돌리면 실제코드에서 다른 클래스에 요청하는 메소드는 전혀 전혀 작동하지 않는다

깡통폰에서 카톡을 보낼 수 없는 것 처럼!

어떻게 동작할지 설정해주지 않으면 아무런 동작을 하지 못 하기 때문에 오류가 발생한다

# 실제 코드

Todo todo = todoRepository.findById(todoId).orElseThrow(() ->
        new InvalidRequestException("Todo not found"));

 

# 테스트 코드

given(todoRepository.findById(anyLong())).willReturn(Optional.of(todo)); # 또는
when(todoRepository.findById(anyLong())).thenReturn(Optional.of(todo)); 
# 아무 Long 형식이 아니라 특정 todoId 를 던져도 된다
when(todoRepository.findById(todoId)).thenReturn(Optional.of(todo));

어떻게 동작할지 설정하는 메서드는 위와 같다

만약 실제 코드의 테스트를 진행할 때, todoRepository.findById 를 실행하는 타이밍이 오면 그냥 아까 만든 todo 를 던져라는 의미

 

실제로 repository 에 가서 찾는게 아닌 그냥 만들어진 값을 던져주기만 하면 된다.

 

추가 예시

# 테스트 코드

given(commentRepository.save(any())).willReturn(comment);
# save 의 결과값을 받아야 할 때
when(XXXRepository.getAnnotaion(XXX.class)).willReturn(mock(XXX.class));
# 특정 클래스 형태의 객체만 받아와야할 때
# 추가로 특정 클래스 형태의 mock 객체를 아무거나 return 하면 되는 경우
when(XXXRepository.findById(XXId)).thenReturn(Optional.empty()); 
# empty 를 return 해주길 바랄때
when(passwordEncoder.matches(password, user.getPassword())).thenReturn(true);
# true 또는 false Return

 


4

// when

테스트 하고싶은 method 를 작성하고 반환값을 받아준다

# 테스트 코드

List<CommentResponse> responses = commentService.getComments(todoId);

이 메서드를 실행했을 때 << 라는 의미로 when 을 사용

 

만약 반환값이 없다면 ( void method1 ( ) 등) 그냥 아래처럼 해주면 된다

# 테스트 코드

commentService.deleteComment(commentId);

 


5

// then

실행 이후 값을 검증해준다

 

1) 반환값 x 성공

verify(commentRepository).findByTodoIdWithUser(todoId);

해당 메서드를 실행했는지 아닌지만 확인한다.

verify(commentRepository, times(1)).findByTodoWithUser(todoId);

해당 메서드를 몇 번 실행했는지 확인한다.

verify(commentRepository, never()).findByTodoWithUser(todoId);

해당 메서드가 실행되지 않았는지 확인한다. 이 메서드를 알기 전까진 times(0) 으로 확인했는데 잘 됨 


 

2) 반환값 o 성공

assertEquals("테스트 내용", responses.get(0).getContents()); #또는
assertThat(responses.get(0).getUser().getEmail()).isEqualTo("user1@example.com");

assertEquals(1, responses.size());

response 가 내가 예상하는 값과 같은 결과값을 가지고 있는지 확인한다.

리스트 출력의 경우 해당 리스트의 길이를 확인한다.

assertThat(temp).isTrue();
assertThat(temp).isFalse();

boolean 값은 참인지 거짓인지 확인한다.

assertThat(responses).isEmpty();
assertThat(responses).isNull();
assertThat(responses).isNotEmpty();
assertThat(responses).isNotNull();

empty 여부와 null 여부를 확인할 수 있다

 

반환값이 있는 경우에도 verify 도 당연히 사용해도 된다! 


 

3) 실패 (throw)

assertThatThrownBy(()-> commentAdminService.deleteComment(commentId))
        .isInstanceOf(InvalidRequestException.class)
        .hasMessage("Comment not found");

어떤 형식의 Exception 이 어떤 메시지를 가지고 오류를 발생시키는지 확인할 수 있음

 

이 경우 when 절에 들어가는 내용을 ThrownBy의 매개변수로 보내기 때문에 when 과 then 을 구분해서 적지않고 

// when & then 등으로 함께 적는다


 

4) 추가

 

JwtFilter 등 반환값이 애매한 경우에도 실행이 제대로 되었는지 여부를 체크하는 verify 방법을 사용한다.

만약 오류가 발생하는 등의 상황을 가정하게 된다면 제대로 오류메시지가 출력되는지 확인하는 과정이 필요하다.

 

Filter 는 GlobalExceptionHandler 로 접근할 수 없기때문에 직접 작성을 해줘야하는데 이번 과제 코드에서는 아래와 같은 방법으로 오류메시지를 출력한다.

# 실제 코드 

    catch (ExpiredJwtException e) {
    log.info("JWT 만료: userId={}, URI={}", e.getClaims().getSubject(), url);
    sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");

private void sendErrorResponse
		(HttpServletResponse response, HttpStatus status, String message) throws IOException {
    response.setStatus(status.value());
    response.setContentType("application/json;charset=UTF-8");

    Map<String, Object> errorResponse = new HashMap<>();
    errorResponse.put("status", status.name());
    errorResponse.put("code", status.value());
    errorResponse.put("message", message);

    response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}

이 때 우리는 값을 확인할 수 없기 때문에 출력되는 모든값을 저장해두고 result 로 뽑아볼 수 있다

# 테스트 코드

# given
StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);

# when
when(response.getWriter()).thenReturn(printWriter);

# then
String result = stringWriter.toString();
assertThat(result).contains("인증이 필요합니다.");
assertThat(result).contains("UNAUTHORIZED");

StringWriter 는 메모리에 문자열을 저장하는 버퍼이고, PrinWriter 는 출력용이다.

테스트에서는 가짜 Writer (printWriter) 를 준비해서 getWriter 대신 사용한다 (when)

String 형식으로 꺼내서 result 에 저장하고 해당 result 에 우리가 생각하는 출력이 잘 이루어졌는지 확인해주면 된다.

 

더보기

Filter 단 또는 interceptor 에서 발생하는 오류는 Global Exception Handler 로 처리할 수 없다!

해당 클래스를 생각해보면 @RestControllerAdvice 가 달려있는데 이는 Controller 계층 이후에서 발생한 예외만 처리한다.

Filter에서 발생한 예외는 DispatcherServlet 에 들어오기 전이므로 하나하나 작성해줘야한다

 

-> 그래서 sendErrorResponse 메서드가 따로 존재한다

보통 구현을 아래와 같은 형식으로 하기 때문에 기억해두기

try {
    filterChain.doFilter(request, response);
} catch (AuthException e) {
    sendErrorResponse(response, HttpStatus.UNAUTHORIZED, e.getMessage());
}

 

  위치 예외 response 방법
Filter, Interceptor DispatcherServlet 이전 직접 응답 생성
또는 HandlerExceptionResolver 로 한 번 더 던져서 
GlobalExceptionHandler 가 처리하도록 할 수 있음 
Controller DispatcherServlet 이후 GlobalExceptionHandler

그렇기 때문에 직접 적어진 Filter 단의 경우 return 값이 없기 때문에 테스트에서 해당 메시지를 확인하려면 아래와 같은 코드가 필수로 필요하다!!

StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);

when(response.getWriter()).thenReturn(printWriter);

6

참고할 점

 

단위테스트의 경우 비즈니스 로직을 검증 하는게 목표기 때문에 주로 Service 계층에서만 작성했다

Controller나 Filter는 Http 요청 흐름 내부에서 여러 클래스들이 같이 동작하기 때문에, 단위 테스트보다는 통합 테스트를 통해 전체 동작을 확인하는 것이 더 적절하다고 판단했다.


통합 테스트 작성

통합 테스트는 여러 클래스가 함께 동작

given, when, then 이 엄격하게 나눠지지 않는다. 이후 나오겠지만 mockMvc.perform 이 when + then 역할을 하기 때문이다!

 

통합테스트 이후 확인되지 않은 구문이 존재하면 해당 부분은 따로 단위테스트를 생성해서 추가해주는 과정을 거쳤다


1

어노테이션을 추가해준다.

@SpringBootTest
@Transactional
@AutoConfigureMockMvc
@SpringBootTest 테스트 시 실제 어플리케이션이랑 비슷한 환경에서 테스트 할 수 있도록 로딩해줌
@Transactional 테스트 실행 후 DB 변경 내용을 자동으로 rollback -> test 용 임시 데이터들이 DB 에 남지 않게 해줌
@AutoConfigureMockMvc MockMvc 를 자동으로 설정해서 Postman 의 역할을 할 수 있게 함 

 

이젠 가짜를 사용하는게 아니라 실제 존재하는 클래스들을 가져와야하므로 클래스를 가져올 때 @Mock 과 @InjectsMock 이 아닌 @Autowired 어노테이션을 붙여서 사용한다. (commentIntegrationTest : 꽤 많은 repository 가 필요하다)

@Autowired
private MockMvc mockMvc;
@Autowired
private CommentRepository commentRepository;
@Autowired
private TodoRepository todoRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private JwtUtil jwtUtil;

 

물론 가짜를 사용해야하는 경우에는 Mock 을 여전히 사용한다

@SpringBootTest
class JwtFilterTest {

    @InjectMocks
    private JwtFilter jwtFilter;
    @Mock
    private JwtUtil jwtUtil;

2

필요한 데이터가 뭐가 있는지 생각해봐야한다.

comment controller 와 service 를 띄워두고 생각해보면

역할에 따른 두 User 와 해당 Token 이 필요하고, 이미 저장되어있는 Todo 에서 api 요청이 일어나기 때문에 해당 값들은 미리 생성해주기로 한다.

 

모든 Test 에서 만들어진 값을 사용할 수 있게 하는 로직을 추가한다

private String userToken;
private String adminToken;

private User user;
private User adminUser;

private Todo savedTodo;

@BeforeEach
void setup(){
    user = userRepository.save(new User("testUser@email.com", "1234", UserRole.USER));
    adminUser = userRepository.save(new User("testAdmin@email.com", "1234", UserRole.ADMIN));

    savedTodo = todoRepository.save(new Todo("title", "contents", "SUNNY", user));

    userToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole());
    adminToken = jwtUtil.createToken(adminUser.getId(), adminUser.getEmail(), adminUser.getUserRole());
}

@BeforeEach 를 붙여서 모든 Test 가 setup 내부의 값에 접근할 수 있도록 한다.

하지만 필요없는 값들도 다 로드되기 때문에 내부에 어떤 값을 초기화해둘지에 대한 고민이 필요함! 최소한으로 해야 비용이 적음

 

+ 현재 save 되는 객체들은 어차피 이 Test 가 끝나면 사라지기 때문에 걱정하지 않아도 된다


3

@Test
@DisplayName("성공 - 댓글 저장")
void save_comment_succeed() throws Exception{

단위테스트와 비슷하게 Test 어노테이션과 Display name 을 작성해준다.

 

단위테스트와 다른 점은 Integration Test 에서는 메서드 옆에 throws Exception 을 붙여줘야한다. 

통합 테스트는 MockMvc 등의 API 가 exception 을 발생시킬 수 있는데, 이 때 테스트코드 내부에서 try-catch 문을 일일히 적어주기 힘들기 때문에 한 번에 throws Exception 을 선언한다


// given 

앞서 말했듯 통합테스트는 when 과 then 이 명확하지 않고, 가짜(mock) 클래스가 아니기 때문에 stubbing 해줄 필요도 Xx

setup 에서 해주지는 않았지만 필요한 값을 세팅해준다 

commentRepository.save(new Comment("이건 첫번째 레슨...", user, savedTodo));
commentRepository.save(new Comment("이건 두번째 레슨...", adminUser, savedTodo));

미리 user 와 savedTodo 를 만들어두었기 때문에 Comment 를 만들기 쉽다.

그냥 만들면 조회를 못 하기 때문에 처음에 given 을 할 때 repository 에 save 하는 내용까지 추가해줘야 한다

 

request DTO 가 있는 경우 성공 - 실패 케이스를 나눠줘야하기 때문에 각 test 안에서 만들어준다

# 테스트 코드

TodoSaveRequest request = new TodoSaveRequest("title", "contents"); # 성공 케이스
TodoSaveRequest request = new TodoSaveRequest("", "contents"); # 실패 케이스 - 필수값 누락

위 예시에서 볼 수 있다시피 통합테스트에서 Validtation 체크를 해줄 수 있기 때문에 통합테스트를 실시하면 entity package 내부의 line coverage 도 올라간다

 


5

// when , // then

 

일단... 형태가 정해져있다 코드부터 살펴보면

mockMvc.perform(post("/todos/{todoId}/comments", savedTodo.getId())
                .header("Authorization", userToken)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(commentSaveRequest)))
                .andExpect(status().isOk())
        .andExpect(jsonPath("$.contents").value("contents"));

 

잘 보일지 모르겠다...... 한 줄씩 순서대로

이런 화면에서 저 POST 와 8080 이후의 endpoint 값 지정

 

 

Headers 중에서도 Authorization 내부의 값을 지정

어떤 형태로 request 를 보낼지 지정하고 writeValueAsString ( ) < 이 괄호안에 request 값을 삽입

이후에 어떤 값이 나올 것을 예상한다는 의미의 andExpect 이후에 상태값 또는 response 값을 작성해주면 된다

 

@Test
@DisplayName("성공 - 댓글 조회")
void getComments_succeed() throws Exception{
    commentRepository.save(new Comment("이건 첫번째 레슨...", user, savedTodo));
    commentRepository.save(new Comment("이건 두번째 레슨...", adminUser, savedTodo));

    mockMvc.perform(get("/todos/{todoId}/comments", savedTodo.getId())
            .header("Authorization", userToken))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.length()").value(2))
            .andExpect(jsonPath("$[0].contents").value("이건 첫번째 레슨..."))
            .andExpect(jsonPath("$[1].contents").value("이건 두번째 레슨..."))
            .andExpect(jsonPath("$[0].user.id").value(user.getId()))
            .andExpect(jsonPath("$[1].user.id").value(adminUser.getId()));
}

리스트의 경우 [ idx ]. 으로 접근해주면 된다 직접 값을 넣어도 되고 우리가 설정한 것에서 getter 를 사용해서 비교해도 상관없다

 실패의 경우에도 같은 방식을 사용한다.


추가

Jwt Filter 테스트가 나한텐 너무... 너무 어려웠기 때문에 해당 부분을 추가로 작성한다

일단 Jwt Filter 는 단위테스트가 더 편했는데 이유는 토큰이 만료되거나, 위조되었거나, 찾을 수 없는 경우를 통합 테스트에서 만들기 너무 어렵기 때문이었다

 

when 또는 given 으로 우리가 원하는 값을 뱉도록 설정해주기로 했다

 

# 테스트 코드

@Test
@DisplayName("성공 - JWT 필터 통과, request attribute 세팅")
void doFilter_succeed() throws Exception {
    # given
    String token = "thisistoken";

    when(request.getRequestURI()).thenReturn("/users/1"); # auth 가 아닌 값
    when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
    when(jwtUtil.substringToken("Bearer " + token)).thenReturn(token);

    Claims claims = mock(Claims.class);
    when(jwtUtil.extractClaims(token)).thenReturn(claims);
    when(claims.getSubject()).thenReturn("1");
    when(claims.get("email")).thenReturn("testUser@email.com");
    when(claims.get("userRole", String.class)).thenReturn("USER");

    # when
    jwtFilter.doFilter(request, response, filterChain);
    # then
    verify(filterChain, times(1)).doFilter(request, response);
}

필요한 메서드들을 적어준다

 

Claims 를 사용하려면 Test 중간에도 Claims 를 사용할 수 있게 build.gradle 설정을 수정해줘야한다

Claims 를 가져오고 Claims 에 접근하는 모든 메서드들에 대해 given 으로 설정해준다

실제 WT Filter 코드에서 설정된 대로 매개변수의 개수를 제대로 넘겨줘야한다....

# 실제 코드

UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));

httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
# 얘는 String.class 도 넘겨야함
httpRequest.setAttribute("userRole", claims.get("userRole", String.class));

 

 

subString 도 JWT Filter 내부에 있는 메서드지만 꼭 when절로 반환값을 작성해줘야한다

 

JwtFilterTest 위 setup 과 mock, 실패코드를 추가로 첨부한다

@ExtendWith(MockitoExtension.class)
class JwtFilterTest {

private JwtFilter jwtFilter;
@Mock
private JwtUtil jwtUtil;
@Mock
private FilterChain filterChain;
@Mock
private HttpServletRequest request;
@Mock
private HttpServletResponse response;

@BeforeEach
void setUp() {
    ObjectMapper objectMapper = new ObjectMapper();
    jwtFilter = new JwtFilter(jwtUtil, objectMapper);
}

 

@Test
@DisplayName("실패 - 만료된 토큰")
void doFilter_expiredJwt_returnsUnauthorized() throws Exception {
    //given

    String token = "thisis.expired.token";
    Claims claims = mock(Claims.class);
    StringWriter stringWriter = new StringWriter();
    PrintWriter printWriter = new PrintWriter(stringWriter);

    when(request.getRequestURI()).thenReturn("/users/1");
    when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
    when(jwtUtil.substringToken("Bearer " + token)).thenReturn(token);
    when(response.getWriter()).thenReturn(printWriter);
    when(jwtUtil.extractClaims(token)).thenThrow(
    	new ExpiredJwtException(null, claims, "인증이 필요합니다."));

    // when
    jwtFilter.doFilter(request, response, filterChain);
    // then
    verify(filterChain, never()).doFilter(request, response);
    verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    String result = stringWriter.toString();
    assertThat(result).contains("인증이 필요합니다.");
}

이 실패코드를 작성할 때 ExpiredJwtException 부분에서 계속 오류가 발생했었는데

다른 Exception (검증 X 토큰, 존재하지 않는 토큰,, 등) 은 매개변수가 1개였지만... 얘는 3개다

꼭!!!! 잘 보기 ... ㅠㅠ 

 

추가로 필요없는 Stubbing 에 대한 오류도 봤다

오류코드에서 필요없는 stubbing 코드가 몇번째 줄인지 친절하게 알려주니까 확인하고 지우기!

Following stubbings are unnecessary (click to navigate to relevant line of code):

  1. -> at org.example.expert.config.JwtFilterTest.doFilter_succeed(JwtFilterTest.java:67)

 


작성하다보니 DisplayName 이 빠지거나 오타가 많아서 추가로 수정하는 과정을 거쳤다

앞으로 클래스를 테스트한다면 해당 클래스를 어떤 테스트로 진행할지 결정하는데 많은 도움이 될 것 같다

 

최근 테스트 주도 개발을 많이 한다고 튜터님이 알려주셔서 안그래도 못 하는 Test 부분을 한 번 잘 끝내보고싶어서 하나하나 작성해봤는데 시간...측면에서 효율성은 전혀 없었지만 (약 20시간 쓴듯하다...) 통합테스트도 해보고 완전 유익한 시간이었던 것 같다..!!

그리고 사람들이 ai 안 쓰고 열심히 했다고 다 복복복 해주셔서 더 뿌듯한 마무리였당

 

끝!!!! 진짜 끝

 

 

'SPARTA 과제 > SPRING' 카테고리의 다른 글

Plus spring 도전과제  (1) 2026.04.06
Plus spring 필수 과제  (0) 2026.04.03
심화) LV 6 코드 리팩토링  (0) 2026.03.07
심화) LV 5 API 로깅  (0) 2026.03.04
심화) LV 0~ LV 4 필수 문제풀이 과정  (0) 2026.03.04