| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- Integration Test
- branch protection
- EC2
- 제2종오류
- ERD설게
- 테스트 코드
- CI
- backend
- 제1종오류
- springboot
- MySQL
- 백엔드 로그인 구현
- Spring Boot
- query optimization
- 책임분리
- JPA
- docker
- SelfHostedRunner
- 백엔드
- Spring Security
- rest api
- SolveGO
- B+Tree
- 틀린문제조회
- Slice Test
- Service Layer
- JWT
- 백엔드 회원가입 구현
- AWS
- performance test
- Today
- Total
노트
[SolveGO #15] 큰 힘에는 큰 책임이 따른다 - Controller Slice Test & Integration Test 본문
[SolveGO #15] 큰 힘에는 큰 책임이 따른다 - Controller Slice Test & Integration Test
forwarder 2026. 7. 6. 18:56

살다 보면, 자신의 권리를 주장하지 못할 때가 있다. 자신이 어떤 행동을 취함으로써 다른 이의 기분이 상하게 할까봐 혹은 자신이 받을 불이익이 걱정되어 자신의 권리를 포기할 때도 있다. 그리고 이런 행위가 반복되면 자신의 행동에 대한 확신 부족으로 이어지는 악습관이 되기도 한다. 더욱이 본인의 권리를 본인이 주장하지 못한다면, 자기 자신을 지킬 수 없게 되고 이는 외부 요인에 매우 취약한 상태로 만듦으로써 본인의 기준보다는 타인의 기준에 본인을 맞추게 되어, 결과적으로는 본인의 삶의 자주성을 상실한채 본인의 삶을 흘려보낼 수 있다.
그렇다면 어떻게 자신의 권리를 주장할 수 있을까? 나에게 이득을 주는 것이면 나의 권리라고 주장할 수 있을까? 농구를 예로 생각해보자. 농구 경기에서 슛을 넣는 것은 경기에 참여한 플레이어로서의 당연한 나의 권리일 것이다. 그러나, 만일 내가 상대편 선수를 고의로 넘어트리고 슛을 넣었다면 어떻게 될까? 이 경우 역시 나에게 이득을 주는 것이기 때문에 나의 권리라고 생각할 수 있을까? 어디까지가 나의 권리일까?
권리란 법에 의해 개인 또는 단체에 부여된 힘이다. 따라서 "법을 준수하는 책임"을 지고 있다면, 그에 따른 권리를 가지게 된다. 예를 들어 민주주의 하에서 인간은 자유권, 평등권, 행복추구권, 참정권을 태어날 때부터 보장하기 때문에 인간이라면 누구나 이에 대한 권리를 주장할 수 있고 다른 이의 권리를 침해해서는 안된다는 책임 또한 지닌다. 농구 경기도 규칙이라는 법을 준수하는 책임을 지면서 플레이 한다면 그에 대한 정당한 권리를 심판에게 보장받을 수 있다.
그렇다면 한번 생각해보자. 어떤 체계 하에서 더 큰 권리를 누리고 싶다면 어떻게 해야할까? 우리 사회가 정한 법은 아무에게나 막대한 권리를 주지 않는다. 더 커지는 책임을 가지는 존재에게 더 큰 권리를 주게 된다. 그리고 만일 권리는 있지만, 그에 따른 책임을 져버린다면 사회는 자정 작용을 통해 그 존재를 축출하게 되고 그 예시는 현실에서도 심심치 않게 볼 수 있다. 그런 의미에서 "큰 힘에는 큰 책임이 따른다"라는 말이 탄생하였을 것이다.

그런데 이는 비단 사회에서만 성립되는 말이 아니다. 우리가 설계하는 소프트웨어 역시 여러 컴포넌트가 긴밀히 소통하는 일종의 사회인바, 우리 사회가 법으로 구성원들을 조율하듯이 일종의 법률이 존재하는데, 이를 SOLID 원칙이라 한다. 예를 들어 SRP(single responsibility principle)는 특정 컴포넌트에게 너무 많은 책임을 주지 않음으로써, 그 컴포넌트의 부담을 줄이고, "너무 큰 힘"을 주지 않으려는 원칙이다.
우리의 소프트웨어 역시 사회라면, 컴포넌트는 어떠한 "권리"를 가질까? 우리가 사회가 설계한 법이 부여하는 권리를 가지듯이, 컴포넌트는 우리가 설계한 구조 안에서 자신이 맡은 기능을 수행할 권리를 가진다. 예를 들어 Controller는 HTTP 요청이 들어오면, 그에 맞는 비즈니스 로직을 SRP 원칙에 따라 본인이 아닌 하위 Service 컴포넌트에 위임하고 알맞은 DTO를 HTTP 응답으로 내보내는 권리를 가진다. 만일 소프트웨어가 Controller에게 너무 많은 힘을 줌으로써, 비즈니스 로직과 DB 접근까지 할 수 있는 권리를 가지게 되면 개발자는 자정작용을 통해 그 Controller를 수정할 것이다. 그렇지 않다면 소프트웨어가 점점 망가지게 될 것이다.
앞서 큰 힘에는 큰 책임이 따른다고 했다. 다시 말해 권리를 주장하기 위해서는 그에 상응하는 책임을 져야 한다는 것인데, 컴포넌트가 자신의 기능인 권리를 주장하는데 필요한 책임은 무엇일까? 답은 명확하다. 자신이 담당하는 기능을 명확하게 수행하는 책임을 지는 것이다. 너무나 당연하지만, 이를 증명하기는 결코 쉽지 않다. 본래 틀림을 증명하는 것은 쉽지만, 맞음을 증명하기 어려운 것과 같은 이치일 것이다. 그렇기에 우리는 차선책인 "테스트 코드"를 사용하는 방안을 취한다. 테스트 코드가 모두 통과한다는 것이 그 컴포넌트가 자신의 기능을 제대로 수행한다는 것의 충분 조건은 아니지만, 적어도 필요 조건이기 때문이다.
정리하자면, 컴포넌트는 자신의 권리인 자신이 맡은 기능을 수행하기 위해서는 적어도 우리가 설계한 모든 테스트 코드를 통과해야 하는 책임을 지니며 컴포넌트가 더 큰 기능을 가질수록 더 많은 기능을 검증하는 테스트 코드를 통과해야 하는 더 큰 책임을 지닌다.
우리는 지금까지 SolveGo 프로젝트의 Service Layer까지 테스트를 완료하여, DB부터 시작하여 Service Layer까지는 그에 해당하는 일종의 책임을 부여한 상태이다. 그렇다면 이제 테스트 영역을 Controller Layer까지 넓혀, 서버의 주요 컴포넌트들이 적어도 테스트 케이스 하에서는 자신에게 부여된 책임을 수행하고 있음을 확인해보자.

Controller Slice Test
현재 Controller의 책임은 HTTP 요청을 받아, 적절한 비즈니스 로직을 하위 컴포넌트인 Service에 위임하고 그 결과를 DTO로 변환하여 반환하는 책임을 지닌다. 따라서 Controller 컴포넌트를 주 테스트 대상으로 하는 동시에 나머지 컴포넌트를 mock 객체로 고정하고, 가짜 HTTP 요청을 만들기 위한 mockMvc를 이용하여 테스트 코드를 작성하자.
여기서 Controller Unit Test가 아닌 Slice Test인 까닭은, Controller만 단일로 테스트하는 것이 아닌 mockMvc로 만들어낸 가짜 HTTP 요청으로 HTTP 요청/응답과 관련된 컴포넌트들(예를 들어 DispatcherServlet)도 같이 검증하기 때문이다.
현재 서버의 아키텍처 구조와 함께, 이번 테스트에 다루어야 할 대상을 살펴보자.

Controller Layer의 Controller는 위에서 보는 바와 같이 4개이고, 모두 테스트해야 할 가치가 있으니(테스트 코드로 고정해야 할 기능을 수행하고 있으니) 이들에 대한 Slice Test를 작성하자.
그러면 UserController부터 테스트 해보자.
package com.kdh.solvego.domain.user.controller;
//...
@Tag(name = "User", description = "사용자 관련 API")
@RestController
@RequestMapping(value="/api/users",produces = MediaType.APPLICATION_JSON_VALUE)
public class UserController {
private final UserService userService;
private final AttemptService attemptService;
public UserController(UserService userService, AttemptService attemptService) {
this.userService = userService;
this.attemptService = attemptService;
}
@Operation(
summary = "회원가입",
description = "사용자 아이디와 비밀번호를 입력받아 새로운 사용자를 생성합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
@ApiResponse(
responseCode = "400",
description = "요청 형식 오류",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "409",
description = "이미 존재하는 username",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public SignupResponse signup(@Valid @RequestBody SignupRequest request){
return userService.signup(request);
}
@Operation(
summary = "틀린 문제 조회",
description = "로그인한 사용자가 틀렸던 문제 목록을 조회합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "틀린 문제 목록 조회 성공"),
@ApiResponse(
responseCode = "401",
description = "인증되지 않은 사용자",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
@SecurityRequirement(name = "bearerAuth")
@GetMapping("/me/wrong-problems")
public List<WrongProblemResponse> getWrongProblems(
Authentication authentication
) {
Long userId = (Long) authentication.getPrincipal();
return attemptService.getWrongProblems(userId);
}
}
기존 UserController는 위와 같았다. 여기서 테스트를 하기 위해서는 다음과 같은 물음을 던져야한다.
"이 컴포넌트의 권리는 무엇인가?", 다시말해 "이 컴포넌트가 수행하는 기능은 어디까지이며, 그렇다면 어디까지가 이 컴포넌트의 책임인가"
이 UserController의 기능은 회원가입과 틀린 문제 조회 기능이다. 따라서 이 기능에 대한 주요 시나리오를 테스트 케이스로 설계하여 검증하면 될 것이다. 그리고 추가로 JWT 인증에 대한 테스트는 하지 않음을 주의하자. 이 JWT 인증에 대한 기능은 해당 UserController의 기능이 아니기 때문에, 자연히 검증할 책임 또한 없다.
package com.kdh.solvego.domain.user.controller;
//...
@WebMvcTest(UserController.class)
@AutoConfigureMockMvc(addFilters = false)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@MockitoBean
private AttemptService attemptService;
@MockitoBean
private JwtTokenProvider jwtTokenProvider;
@Test
@DisplayName("회원가입에 성공하면 201 Created와 userId를 반환한다")
void signup_success() throws Exception {
// given
SignupRequest request = new SignupRequest("username", "1234");
when(userService.signup(any(SignupRequest.class)))
.thenReturn(new SignupResponse(1L));
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.userId").value(1L));
verify(userService).signup(request);
}
@Test
@DisplayName("회원가입 요청값이 올바르지 않으면 400 Bad Request를 반환한다")
void signup_fails_when_request_is_invalid() throws Exception {
// given
SignupRequest request = new SignupRequest("", "1234");
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
verify(userService, never()).signup(any(SignupRequest.class));
}
@Test
@DisplayName("중복된 username이면 409 Conflict를 반환한다")
void signup_fails_when_username_is_duplicated() throws Exception {
// given
SignupRequest request = new SignupRequest("username", "1234");
when(userService.signup(any(SignupRequest.class)))
.thenThrow(new DuplicateUsernameException());
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
verify(userService).signup(request);
}
@Test
@DisplayName("틀린 문제 목록 조회에 성공한다")
void get_wrong_problems_success() throws Exception {
// given
Long userId = 1L;
Authentication authentication =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
List<WrongProblemResponse> response = List.of(
new WrongProblemResponse(1L, "problem title")
);
when(attemptService.getWrongProblems(userId))
.thenReturn(response);
// when & then
mockMvc.perform(get("/api/users/me/wrong-problems")
.principal(authentication))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$[0].problemId").value(1L))
.andExpect(jsonPath("$[0].title").value("problem title"));
verify(attemptService).getWrongProblems(userId);
}
}
각 테스트마다 하위 컴포넌트는 mock 객체로 고정하여, given 절에서 행동 방식을 사전에 결정해 두었으며, mockMvc의 perform() 메서드를 이용하여 HTTP 요청을 시뮬레이션하였다. 그리고 verify()를 이용하여 controller의 동작이 예상한 바와 일치하는지를 체크하도록 하였다.

해당 테스트는 모두 통과한 것을 확인할 수 있다.
지금 수행한 이 테스트는 하부 컴포넌트를 mock 객체로 고정하여 오직 controller와 그 주변 컴포넌트만을 검증한 것이다. 여기서 주목할 점은 원소 s가 (적어도 테스트 케이스 하에서는) 안전하다고 해서, 그것이 집합 S에서도 여전히 (마찬가지로 적어도 테스트 케이스 하에서는) 안전하다는 뜻은 아니다. 단독으로 존재하였을 때는 올바르게 동작하던 것이 시스템의 일부가 되었을 때는 예상치 못한 상호작용으로 인하여 소위 "되던 게 안 되네" 가 가능하기 때문이다. 따라서 이를 위해서 전체 Integration Test를 진행하여야 한다.
앞서 Slice Test가 하부 컴포넌트를 mock 객체로 고정하였다면, Integration Test는 그 고정한 하위 컴포넌트들을 실제 객체로 사용하여 실제 전체 시스템의 흐름을 시뮬레이션 하는 과정이다. 코드는 아래와 같다.
package com.kdh.solvego.domain.user.controller;
//...
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("회원가입에 성공하면 사용자가 저장되고 201 Created를 반환한다")
void signup_success() throws Exception {
// given
String requestBody = """
{
"username": "username1",
"password": "1234"
}
""";
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.userId").exists());
assertThat(userRepository.existsByUsername("username1")).isTrue();
}
@Test
@DisplayName("이미 존재하는 username으로 회원가입하면 409 Conflict를 반환한다")
void signup_fails_when_username_is_duplicated() throws Exception {
// given
String requestBody = """
{
"username": "username2",
"password": "1234"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated());
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isConflict());
}
@Test
@DisplayName("JWT로 인증된 사용자는 자신이 틀린 문제 목록을 조회할 수 있다")
void get_wrong_problems_success() throws Exception {
// given
String signupRequestBody = """
{
"username": "username3",
"password": "1234"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(signupRequestBody))
.andExpect(status().isCreated());
String loginRequestBody = """
{
"username": "username3",
"password": "1234"
}
""";
MvcResult loginResult = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginRequestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").exists())
.andReturn();
String loginResponseBody = loginResult.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode loginJsonNode = objectMapper.readTree(loginResponseBody);
String accessToken = loginJsonNode.get("accessToken").asText();
assertThat(accessToken).isNotBlank();
String problemCreateRequestBody = """
{
"title": "problem title",
"description": "problem description",
"blackStones": [
{ "x": 3, "y": 3 }
],
"whiteStones": [
{ "x": 4, "y": 4 }
],
"nextPlayer": "BLACK",
"answerPosition": {
"x": 10,
"y": 10
}
}
""";
MvcResult problemCreateResult = mockMvc.perform(post("/api/problems")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(problemCreateRequestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.problemId").exists())
.andReturn();
String problemCreateResponseBody = problemCreateResult.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode problemJsonNode = objectMapper.readTree(problemCreateResponseBody);
Long problemId = problemJsonNode.get("problemId").asLong();
String wrongAttemptRequestBody = """
{
"selectedPosition": {
"x": 1,
"y": 1
}
}
""";
mockMvc.perform(post("/api/problems/{problemId}/attempts", problemId)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(wrongAttemptRequestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.isCorrect").value(false));
// when & then
mockMvc.perform(get("/api/users/me/wrong-problems")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].problemId").value(problemId))
.andExpect(jsonPath("$[0].title").value("problem title"));
}
@Test
@DisplayName("JWT 없이 틀린 문제 목록을 조회하면 401 Unauthorized를 반환한다")
void get_wrong_problems_fails_without_jwt() throws Exception {
mockMvc.perform(get("/api/users/me/wrong-problems"))
.andExpect(status().isUnauthorized());
}
}
앞서 말한 바와 마찬가지로 Slice Test의 mock 객체로 고정되었던 하위 컴포넌트들이 위 Integration Test는 실제 동작을 테스트하기 위해 @Autowired로 실제 객체가 주입되고 있는 것을 확인할 수 있다. mockMvc로 요청을 만들어서 그것을 assert나 expect로 검증하는 과정은 앞서 설명했으므로 생략하겠다.

Integration Test 또한 성공하였고, 나머지 3개의 Controller에 대한 Slice Test와 Integration Test 역시 동일한 방식으로 모두 통과하였다.
한편 UserController 테스트를 진행하는 과정에서 Concurrency에 대한 테스트도 진행하였는데 여러 스레드가 동시에 같은 username으로 회원가입을 시도할 때, existsByUsername() 사전 검사를 여러 요청이 동시에 통과하는 race condition이 발생하였다. DB의 unique constraint가 중복 저장 자체는 막았지만, 이때 발생한 DataIntegrityViolationException이 409 Conflict로 변환되지 않아 테스트가 실패하였다. 이는 추후 트러블슈팅 글에서 따로 다루도록 하겠다.
아래는 지금까지의 모든 테스트 코드에 대한 테스트 결과이다.

이제 시스템의 컴포넌트들은 자신의 동작에 대한 "책임"을 테스트 코드로써 보였다. 다음글에서는 코드가 수정되더라도, 이 컴포넌트들이 여전히 자신들이 테스트 케이스 하에서 잘 동작하는 것을 지속적으로 보이도록, CI(Continuous Integration) 작업을 시작하자.
'Project > SolveGO' 카테고리의 다른 글
| [SolveGO #17] 땅을 다지는 기초공사 - Docker 패키징 & 수동 배포 (0) | 2026.07.10 |
|---|---|
| [SolveGO #16] "맘대로 해, 되감으면 되니까" - Git과 CI 적용 (0) | 2026.07.08 |
| [SolveGO #14] 롤 밴픽의 원리 - Service Unit Test & Integration Test (0) | 2026.07.05 |
| [SolveGO #13] 실력 상승의 정도(正道) - Repository Slice Test (0) | 2026.07.03 |
| [SolveGO #12] 자신감 있게 뽐내며 걷기 - Swagger 도입 (0) | 2026.07.01 |