[인용 및 참고 출처]
1. 구글 검색: 티스토리, "@Slf4j "테스트 코드", 테스트 코드에서 @Slf4j 사용하기, (2025.01.28)
2. 구글 검색: SLF4J Manual, "Spring Slf4j docs", SLF4J user manual, (2025.01.28)
[문제]
package org.example.expert;
import java.time.LocalDateTime;
import org.example.expert.common.dto.AuthUserDto;
import org.example.expert.common.entity.Todo;
import org.example.expert.domain.user.dto.response.UserResponseDto;
import org.example.expert.common.entity.User;
import org.example.expert.common.enums.UserRole;
import org.springframework.test.util.ReflectionTestUtils;
public class SharedData {
public static final AuthUserDto AUTH_USER_DTO = new AuthUserDto(
1L,
"user1@test.com",
UserRole.USER,
"사용자1"
);
public static final User USER = User.fromAuthUser(AUTH_USER_DTO);
public static final UserResponseDto USER_RESPONSE_DTO = new UserResponseDto(USER);
public static final Todo TODO = new Todo(
"일정 제목",
"일정 내용",
"날씨",
USER
);
static {
ReflectionTestUtils.setField(
TODO,
"id",
1L
);
ReflectionTestUtils.setField(
TODO,
"createdAt",
LocalDateTime.of(
2025,
1,
15,
10,
0,
0,
401000000
)
);
ReflectionTestUtils.setField(
TODO,
"updatedAt",
LocalDateTime.of(2025,
1,
15,
10,
0,
0,
401000000
)
);
}
}
package org.example.expert.domain.todo.controller;
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;
import org.example.expert.SharedData;
import org.example.expert.common.exception.notfound.TodoNotFoundException;
import org.example.expert.domain.todo.dto.response.TodoResponseDto;
import org.example.expert.domain.todo.service.TodoService;
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.test.web.servlet.MockMvc;
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@Test
void todo_단건_조회에_성공한다() throws Exception {
// given
TodoResponseDto responseDto = new TodoResponseDto(
SharedData.TODO,
SharedData.USER_RESPONSE_DTO
);
long todoId = responseDto.getId();
String title = responseDto.getTitle();
String contents = responseDto.getContents();
String weather = responseDto.getWeather();
long userId = responseDto.getUser().getId();
String email = responseDto.getUser().getEmail();
String createdAt = responseDto.getCreatedAt().toString();
String updatedAt = responseDto.getUpdatedAt().toString();
// when
when(todoService.getTodo(todoId)).thenReturn(responseDto);
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(todoId))
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.contents").value(contents))
.andExpect(jsonPath("$.weather").value(weather))
.andExpect(jsonPath("$.user.id").value(userId))
.andExpect(jsonPath("$.user.email").value(email))
.andExpect(jsonPath("$.createdAt").value(createdAt))
.andExpect(jsonPath("$.updatedAt").value(updatedAt));
}
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 2L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new TodoNotFoundException());
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ERRNF02"))
.andExpect(jsonPath("$.errorMessage").value("Todo is not found."));
}
}
Spring Security 적용 후 테스트 코드를 실행하니 테스트 성공 실패 여부가 아니라 오류가 발생했다. 무언가 설정이 되었을 거란 생각으로 오류 메시지를 찬찬히 읽었다.
[원인]
NoSuchBeanDefinitionException:
// Spring이 특정 타입의 빈(bean)을 찾지 못해서 발생한 예외
No qualifying bean of type 'org.example.expert.common.utils.JwtUtil' available:
// [1] org.example.expert.common.utils.JwtUtil:
// Spring이 찾으려고 한 타입의 빈
// [2] 문제:
// 해당 타입의 빈이 application context에 등록되지 않음
// 등록 시 사용할 수 있는 어노테이션(annotation)
// → @Component, @Service, 또는 @Configuration
// [3] 오류 메시지 의미:
// JwtUtil 클래스가 빈으로 등록되지 않은 상태로 의존성 주입을 시도함
expected at least 1 bean which qualifies as autowire candidate.
// [의미]
// Spring은 최소한 적합한 빈이 하나는 있어야 한다고 예상함
// → 'JwtUtil' 타입에 해당하는 빈이 application context에 있어야 하는데,
// 빈이 등록되지 않아서 Spring이 해당 빈을 주입할 수 없음
Dependency annotations: {}
// JwtUtil을 주입하려는 곳에 의존성 주입 어노테이션이 없거나 잘못 설정됨
// 의존성 주입 어노테이션 예시:
// → @Autowired, @Inject
// → 해당 어노테이션이 없으면 Spring은 해당 필드를 빈으로 자동 주입할 수 없음
위와 같이 오류 메시지를 읽은 후, JwtUtil 클래스를 빈(bean)으로 주입했다. 주입 방식에는 두 가지가 있었다.
// [1/2] @Import 어노테이션으로 빈(bean) 주입
@Import(JwtUtil.class)
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
package org.example.expert.domain.todo.controller;
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;
import org.example.expert.SharedData;
import org.example.expert.common.exception.notfound.TodoNotFoundException;
import org.example.expert.common.utils.JwtUtil;
import org.example.expert.domain.todo.dto.response.TodoResponseDto;
import org.example.expert.domain.todo.service.TodoService;
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.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
@Import(JwtUtil.class)
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@Test
void todo_단건_조회에_성공한다() throws Exception {
// given
TodoResponseDto responseDto = new TodoResponseDto(
SharedData.TODO,
SharedData.USER_RESPONSE_DTO
);
long todoId = responseDto.getId();
String title = responseDto.getTitle();
String contents = responseDto.getContents();
String weather = responseDto.getWeather();
long userId = responseDto.getUser().getId();
String email = responseDto.getUser().getEmail();
String createdAt = responseDto.getCreatedAt().toString();
String updatedAt = responseDto.getUpdatedAt().toString();
// when
when(todoService.getTodo(todoId)).thenReturn(responseDto);
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(todoId))
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.contents").value(contents))
.andExpect(jsonPath("$.weather").value(weather))
.andExpect(jsonPath("$.user.id").value(userId))
.andExpect(jsonPath("$.user.email").value(email))
.andExpect(jsonPath("$.createdAt").value(createdAt))
.andExpect(jsonPath("$.updatedAt").value(updatedAt));
}
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 2L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new TodoNotFoundException());
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ERRNF02"))
.andExpect(jsonPath("$.errorMessage").value("Todo is not found."));
}
}
// [2/2] @WebMvcTest와 includeFilters로 특정 빈(bean)을 주입하기
@WebMvcTest(
value = TodoController.class, // 테스트할 클래스 지정
includeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
// 정규식(regex)을 이용해 빈을 필터링
pattern = "org.example.expert.common.utils.JwtUtil"
// 포함하려는 빈의 경로에 맞는 빈을 필터링
// 빈의 경로: 패키지 이름 + 클래스 이름
// JwtUtil 클래스만 포함함
)
)
class TodoControllerTest {
package org.example.expert.domain.todo.controller;
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;
import org.example.expert.SharedData;
import org.example.expert.common.exception.notfound.TodoNotFoundException;
import org.example.expert.domain.todo.dto.response.TodoResponseDto;
import org.example.expert.domain.todo.service.TodoService;
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.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(
value = TodoController.class,
includeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "org.example.expert.common.utils.JwtUtil"
)
)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@Test
void todo_단건_조회에_성공한다() throws Exception {
// given
TodoResponseDto responseDto = new TodoResponseDto(
SharedData.TODO,
SharedData.USER_RESPONSE_DTO
);
long todoId = responseDto.getId();
String title = responseDto.getTitle();
String contents = responseDto.getContents();
String weather = responseDto.getWeather();
long userId = responseDto.getUser().getId();
String email = responseDto.getUser().getEmail();
String createdAt = responseDto.getCreatedAt().toString();
String updatedAt = responseDto.getUpdatedAt().toString();
// when
when(todoService.getTodo(todoId)).thenReturn(responseDto);
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(todoId))
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.contents").value(contents))
.andExpect(jsonPath("$.weather").value(weather))
.andExpect(jsonPath("$.user.id").value(userId))
.andExpect(jsonPath("$.user.email").value(email))
.andExpect(jsonPath("$.createdAt").value(createdAt))
.andExpect(jsonPath("$.updatedAt").value(updatedAt));
}
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 2L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new TodoNotFoundException());
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ERRNF02"))
.andExpect(jsonPath("$.errorMessage").value("Todo is not found."));
}
}
이렇게 둘 중 하나를 골라서 JwtUtil 클래스를 주입한 후 테스트 코드를 다시 실행한 결과는 아래 사진과 같았다.
그렇다.
테스트 코드를 실행할 때 로그인한 사용자 없어 모든 테스트에서 401 Unauthorized 오류가 발생했다. 테스트용 JWT를 직접 작성해 사용하는 방법도 있었지만, 이 테스트 코드의 목적은 컨트롤러(Controller)가 요청을 올바르게 처리하는지 확인하기였으므로 다음과 같은 순서로 문제를 해결했다.
// [1/4] 인증 관련 기능에 필요한 빈(Bean) 주입
@Import({JwtUtil.class, JwtFilter.class, SecurityConfig.class})
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
우선 WebMvcTest는 컨트롤러 테스트에 필요한 컴포넌트(component)만 스캔(scan)하여 불필요한 빈을 불러오지 않고 테스트를 빠르게 실행할 수 있었지만, @Configuration이 붙은 빈은 자동으로 포함되지 않는 제약이 있었다. 이런 이유로 JWT 인증과 관련된 JwtUtil, JwtFilter, SecurityConfig 빈이 테스트 환경에 제대로 주입되지 않아 인증 관련 기능을 테스트 코드에서 실행할 수 없었다. 이 문제는 @Import를 사용해 각 클래스를 직접 빈으로 주입하여 해결했다.
// [2/4] JwtFilter 수정하기 [수정 전]
if (bearerJwt == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"JWT 토큰이 필요합니다."
);
return;
}
// [2/4] JwtFilter 수정하기 [수정 후]
if (bearerJwt == null) {
filterChain.doFilter(request, response);
return;
}
그 다음으로는 JwtFilter에서 Jwt가 null일 때 처리하는 부분을 수정했다. 원래 Jwt가 null일 땐 response.sendError()로 Jwt 토큰이 필요하다는 BAD_REQUEST(400) 응답을 보냈지만, 수정 후에는 'filterChain.doFilter(request, response)'를 호출하여, 토큰이 없으면 기존의 JwtFilter 대신 Spring Security에서 요청을 처리하도록 했다.
// [3/4] build.gradle에 의존성 추가하기
testImplementation 'org.springframework.security:spring-security-test'
JwtFilter를 수정한 뒤에는 build.gradle에 Spring Security Test 의존성을 추가했다.
// [4/4] @WithMockUser 어노테이션 추가하기
@WithMockUser
@Test
void todo_단건_조회에_성공한다() throws Exception {
// [4/4] @WithMockUser 어노테이션 추가하기
@WithMockUser
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
Spring Security Test 의존성을 추가한 다음에는 테스트 메서드(method)마다 '@WithMockUser' 어노테이션(annotation)을 추가해, 가짜 사용자를 인증 처리해서 '토큰이 없다'는 예외가 발생하지 않도록 했다.
[해결]
package org.example.expert.domain.todo.controller;
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;
import org.example.expert.SharedData;
import org.example.expert.common.config.SecurityConfig;
import org.example.expert.common.exception.notfound.TodoNotFoundException;
import org.example.expert.common.filter.JwtFilter;
import org.example.expert.common.utils.JwtUtil;
import org.example.expert.domain.todo.dto.response.TodoResponseDto;
import org.example.expert.domain.todo.service.TodoService;
import org.junit.jupiter.api.DisplayName;
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.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
@Import({JwtUtil.class, JwtFilter.class, SecurityConfig.class})
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@WithMockUser
@DisplayName("할 일 단건 조회 - 성공: 유효한 ID로 조회")
@Test
void todo_단건_조회에_성공한다() throws Exception {
// given
TodoResponseDto responseDto = new TodoResponseDto(
SharedData.TODO,
SharedData.USER_RESPONSE_DTO
);
long todoId = responseDto.getId();
String title = responseDto.getTitle();
String contents = responseDto.getContents();
String weather = responseDto.getWeather();
long userId = responseDto.getUser().getId();
String email = responseDto.getUser().getEmail();
String createdAt = responseDto.getCreatedAt().toString();
String updatedAt = responseDto.getUpdatedAt().toString();
// when
when(todoService.getTodo(todoId)).thenReturn(responseDto);
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(todoId))
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.contents").value(contents))
.andExpect(jsonPath("$.weather").value(weather))
.andExpect(jsonPath("$.user.id").value(userId))
.andExpect(jsonPath("$.user.email").value(email))
.andExpect(jsonPath("$.createdAt").value(createdAt))
.andExpect(jsonPath("$.updatedAt").value(updatedAt));
}
@WithMockUser
@DisplayName("할 일 단건 조회 - 실패: 존재하지 않는 ID로 조회")
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 2L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new TodoNotFoundException());
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ERRNF02"))
.andExpect(jsonPath("$.errorMessage").value("Todo is not found."));
}
}
문제가 발생한 원인을 찾고 순서대로 해결한 뒤, 테스트 코드를 실행하니 마침내 테스트 성공 결과를 얻을 수 있었다. 테스트 코드가 잘 실행되는지 확인한 후에는 @WithMockUser 어노테이션 설명을 읽었다.
[@WithMockUser 설명]
이 어노테이션은 WithSecurityContextTestExecutionListener와 함께 사용될 때 테스트 메서드에 추가되어 가짜 사용자로 인증된 상태에서 해당 테스트 메서드가 실행되도록 하며, Spring Security 4.0부터 제공된다. MockMvc와 함께 사용될 때 설정되는 SecurityContext의 속성은 다음과 같다.
(1) SecurityContext는 SecurityContextHolder.createEmptyContext()로 생성된다.
(2) SecurityContext는 UsernamePasswordAuthenticationToken으로 이루어지며 이 토큰에 포함된 정보는 다음과 같다.
① username: 어노테이션에서 지정한 value() 또는 username() 값
② GrantedAuthority: 어노테이션에서 지정한 roles() 값
③ password: 어노테이션에서 지정한 password() 값
[추가 학습]
설명을 읽었을 때, @WithMockUser 어노테이션 값을 별도로 설정하지 않으면 기본값이 무엇인지 궁금해져서 @Sl4fj 어노테이션을 사용하여 확인했다.
// [1/4] build.gradle에 의존성 추가하기
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
// [2/4] @Slf4j 어노테이션 추가하기
@Slf4j
@Import({JwtUtil.class, JwtFilter.class, SecurityConfig.class})
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
// [3/4] 테스트 코드 안에서 UserDetails 값을 가져오기
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
// [4/4] 테스트 코드 안에서 로그 입력하기
log.info("Username: {}", userDetails.getUsername());
log.info("GrantedAuthority: {}", userDetails.getAuthorities());
log.info("Password: {}", userDetails.getPassword());
/**
* Obtain the current 'SecurityContext'.
* == 현재의 SecurityContext를 반환한다.
* == 인증된 사용자나 보안 관련 정보를 포함하는 객체를 가져온다.
*
* @return the security context (never 'null')
* == 항상 SecurityContext를 반환하며, 그 값은 null이 절대 아니다.
*/
public static SecurityContext getContext() {
return strategy.getContext();
}
/**
* Obtains the currently authenticated principal,
* or an authentication request token.
* 현재 인증된 'Principal' 객체 또는 인증 요청 토큰을 반환한다.
*
* @return the 'Authentication' or 'null'
* if no authentication information is available
* == 'Authentication' 객체 또는 인증 정보가 없으면 null 값을 반환한다.
*/
Authentication getAuthentication();
/**
* @return the 'Principal' being authenticated
* or the authenticated principal after authentication.
* == 인증 중이거나 인증이 완료된 'Principal' 객체를 반환한다.
*/
Object getPrincipal();
package org.example.expert.domain.todo.controller;
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;
import lombok.extern.slf4j.Slf4j;
import org.example.expert.common.config.SecurityConfig;
import org.example.expert.common.exception.notfound.TodoNotFoundException;
import org.example.expert.common.filter.JwtFilter;
import org.example.expert.common.utils.JwtUtil;
import org.example.expert.domain.todo.service.TodoService;
import org.junit.jupiter.api.DisplayName;
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.context.annotation.Import;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
@Slf4j
@Import({JwtUtil.class, JwtFilter.class, SecurityConfig.class})
@WebMvcTest(value = TodoController.class)
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TodoService todoService;
@WithMockUser
@DisplayName("할 일 단건 조회 - 실패: 존재하지 않는 ID로 조회 시 예외 발생")
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
log.info("Username: {}", userDetails.getUsername());
log.info("GrantedAuthority: {}", userDetails.getAuthorities());
log.info("Password: {}", userDetails.getPassword());
// given
long todoId = 2L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new TodoNotFoundException());
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ERRNF02"))
.andExpect(jsonPath("$.errorMessage").value("Todo is not found."));
}
}
로그를 확인한 결과, 별도로 값을 설정하지 않은 @WithMockUser 어노테이션의 기본값은 다음과 같다는 점을 알 수 있었다.
① username: user
② GrantedAuthority: [ROLE_USER]
③ password: password
'Troubleshooting: 무엇이 문제였는가? > 플러스 프로젝트' 카테고리의 다른 글
10단계: "세상에, 'HttpMessageNotWritableException'이라니! 순환 참조에 걸린 사람? 저요!" (수정 중) (0) | 2025.01.21 |
---|---|
10단계: Cannot invoke "Object.getClass()" because "constant" is null (0) | 2025.01.20 |
10단계: Null이 아니라 널 보고 싶어요, 이메일 씨 (0) | 2025.01.20 |