1. 도입부 (Introduction)
백엔드 프로젝트를 리팩토링하거나 새로운 기능을 추가할 때, 가장 빈번하게 발생하는 참사는 "잘 돌아가던 기존의 도메인 차단 규칙이나 인가 장벽이 소리 소문 없이 해제되는 것"이다. 예를 들어, 팔로우 테이블의 쿼리 구조를 성능 튜닝하기 위해 수정했더니 "자기 자신 팔로우 금지" 정책이나 "차단한 유저 팔로우 금지"와 같은 네거티브 수문장 로직이 누락되어 버리는 현상이다.
동작 자체는 에러 없이 200 OK로 완료되기 때문에, 비즈니스 무결성이 붕괴되었다는 사실은 테스트 코드에서 꼼꼼히 잡아내지 않으면 릴리즈 이후 실제 불량 유저들의 부정 거래나 어드민 탈취 같은 치명적인 보안 사고가 터진 뒤에야 감지된다.
사용자 도메인처럼 규제와 제한이 빼곡한 시스템에서는 "무엇이 허용되는가"보다 "무엇이 철저하게 금지되어야 하는가"를 검증하는 실패 케이스 중심의 테스트 설계가 비즈니스의 생명줄이 된다. 3주차에 우리가 설계한 온보딩, 팔로우, 차단, 권한, 탈퇴 수명주기 정책들이 리팩토링 시의 거친 파도 속에서도 절대로 무너지지 않도록 테스트 코드로 완벽하게 잠금 장치(Locking)를 채우는 다단계 테스트 아키텍처 수립 공식을 정리해 본다.
2. 주요 특징 및 핵심 로직 (Main Features & Logic)
안전한 사용자 도메인 테스트 아키텍처를 구축하려면, 검증하려는 정책의 "무게"와 "환경 의존성"에 맞춰 테스트의 격리 수준(Tiers)을 분리해야 한다. 스프링을 띄우지 않는 순수 자바 단위 테스트부터 스프링 시큐리티 필터 체인까지 녹여낸 슬라이스 통합 테스트까지의 경계는 다음과 같이 정의된다.

이 3단계 테스트 결합 방벽을 통해 도메인의 비즈니스 규칙을 완벽한 계약(Contract)으로 강제할 수 있다.
| 테스트 분류 | 주요 대상 및 파일 경로 | 핵심 검증 목표 및 기법 | 비즈니스 정합성 가치 |
| 도메인 엔티티 테스트 | User.java (Domain) | 정지 상태 전이, Strike 누적 연쇄 규칙 | 비즈니스 라이프사이클 규칙 훼손 방지 |
| 서비스 비즈니스 테스트 | ProfileService, UserBlockService, UserWithdrawService | 자기자신 차단, 양방향 차단 예외, 멱등 no-op 흐름, 탈퇴 시 Cascade 역순 삭제 순서 검증 | 리팩토링 시 로직 유실(Regression) 방지 및 Mock 객체를 통한 고속 실행 보장 |
| 보안 경계 통합 테스트 | SecurityConfig, JwtAuthenticationFilter | 만료 토큰 접근 시 401, 일반 유저 어드민 리소스 진입 시 403 API 응답 | 필터 설정 오타 및 인가 구멍 방어 |
3. 상세 가이드 및 심층 분석 (Detailed Guide)
A) [1단계] 순수 도메인 상태 머신 검증 (Entity 단위 테스트)
외부 인프라(DB) 의존성 없이 자바 가상머신 위에서 엔티티 내부의 전이 함수들이 오차 없이 작동하는지 초고속으로 검증하는 장치다.
package com.example.platform.user.domain;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UserEntityTest {
@Test
@DisplayName("징계 처리 시 등급과 무관하게 무조건 누적 Strike 카운트가 1 증가한다")
void warn_shouldIncreaseStrikeCount() {
// given: 일반 기본 유저 객체 생성 (PrePersist 상태 모사)
User user = new User("normalUser");
user.prePersist(); // 기본값 세팅 트리거
// when: 경고 및 정지 집행
user.warn();
user.suspendForDays(7);
// then: 누적 카운트가 정확히 2회인지 증명
assertThat(user.getModerationStrike()).isEqualTo(2);
}
@Test
@DisplayName("이미 영구 정지 처리(Permanently Banned)된 유저는 어떠한 제재 상태로도 재전이될 수 없다")
void banPermanently_blockedReTransition() {
// given: 영구 정지 상태인 사용자 생성
User user = new User("bannedUser");
user.prePersist();
user.banPermanently(); // 영구 정지 확정 (Strike = 1)
// when & then: 추가 징계 시 예외가 발생하며 Strike 훼손이 방어되는지 검증
assertThatThrownBy(() -> user.warn())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("이미 영구 추방된 사용자의 제재 상태는 전이할 수 없습니다.");
// 최종 Strike 개수는 추가 증가 없이 1건으로 격리되어야 함
assertThat(user.getModerationStrike()).isEqualTo(1);
}
}
B) [2단계] 비즈니스 제약 및 멱등성 검증 (Service 단위 테스트)
외부 의존성 테이블들을 Mock 객체로 안전하게 고립시킨 뒤, 예외를 던지거나 조용히 빠져나가는(no-op) 흐름 정밀 제어부 검증이다.
package com.example.platform.relationship.service;
import com.example.platform.member.domain.Member;
import com.example.platform.member.repository.MemberRepository;
import com.example.platform.relationship.domain.Follow;
import com.example.platform.relationship.repository.FollowRepository;
import com.example.platform.relationship.repository.BlockRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock private FollowRepository followRepository;
@Mock private BlockRepository blockRepository;
@Mock private MemberRepository memberRepository;
@InjectMocks
private RelationshipService relationshipService;
private Member me;
private Member target;
@BeforeEach
void setUp() {
me = spy(new Member(1L, "MyNickname"));
target = spy(new Member(2L, "TargetNickname"));
}
@Test
@DisplayName("차단 관계에 속해 있는 사용자에게는 어떠한 경우에도 팔로우를 신청할 수 없다")
void follow_whenBlockedRelation_thenThrowException() {
// given: 상대와 나 사이에 차단 이력이 존재하도록 Mock 세팅 (양방향 차단 조건 참)
when(memberRepository.findById(me.getId())).thenReturn(Optional.of(me));
when(memberRepository.findById(target.getId())).thenReturn(Optional.of(target));
when(blockRepository.existsRelationBetween(me.getId(), target.getId())).thenReturn(true);
// when & then: 차단 관계에서의 팔로우 시도가 예외를 발생시키는지 단정
assertThrows(IllegalArgumentException.class, () -> {
relationshipService.follow(me.getId(), target.getId());
});
// 물리적 쓰기 저장소 save 호출 및 알림 이벤트 전송이 완전히 격리 차단되어 호출되지 않았음을 보장
verify(followRepository, never()).save(any(Follow.class));
}
@Test
@DisplayName("이미 팔로우된 상대에게 중복 팔로우 요청 시 DB 저장을 다시 호출하지 않고 조용히 성공을 보장(no-op)해야 한다")
void follow_duplicateRequest_shouldBeIdempotentNoOp() {
// given: 이미 팔로우 관계가 존재하는 상태 매핑
when(memberRepository.findById(me.getId())).thenReturn(Optional.of(me));
when(memberRepository.findById(target.getId())).thenReturn(Optional.of(target));
when(blockRepository.existsRelationBetween(me.getId(), target.getId())).thenReturn(false);
when(followRepository.existsByFollowerAndFollowing(me, target)).thenReturn(true); // 중복 참 설정
// when: 팔로우 메서드 중복 호출
relationshipService.follow(me.getId(), target.getId());
// then: 중복 검출 조건부 수문장에 의해 저장 명령이 절대로 재발행되지 않았는지 확인
verify(followRepository, never()).save(any(Follow.class));
}
}
C) [3단계] 보안 필터 체인 및 표준 응답 검증 (Slice 통합 테스트)
실제 HTTP 컨텍스트를 가동하여 인가 핸들러의 HTTP 원시 응답 코드가 규격화되어 떨어지는지 화이트 박스 상태로 진단한다.
package com.example.platform.report.controller;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithAnonymousUser;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class ReportSecurityIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithAnonymousUser // 로그인하지 않은 익명 주체로 요청 위장
@DisplayName("토큰이 누락된 사용자가 어드민 자원에 접근하려는 시도는 규격화된 401 에러 패킷을 수용한다")
void adminResource_anonymousAccess_401Unauthorized() throws Exception {
mockMvc.perform(post("/api/admin/sanction/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized()) // 401 코드 단정
.andExpect(jsonPath("$.success").value(false)) // 공통 ApiResponse 규격 체크
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"))
.andExpect(jsonPath("$.message").value("인증 정보가 비어있거나 무효합니다. 재인증이 필요합니다."));
}
@Test
@WithMockUser(roles = "USER") // ROLE_USER 권한을 가졌지만 ROLE_ADMIN은 없는 사용자로 요청 위장
@DisplayName("인증에는 성공했으나 권한 등급이 부족한 일반 사용자가 어드민 리소스에 진입 시도 시 403 코드로 거절된다")
void adminResource_lowRoleAccess_403Forbidden() throws Exception {
mockMvc.perform(post("/api/admin/sanction/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden()) // 403 코드 단정
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("FORBIDDEN"))
.andExpect(jsonPath("$.message").value("해당 권한으로는 해당 리소스에 접근할 수 없습니다."));
}
}
4. 회귀 탐지 시나리오 및 복구 로직 (Regression Defending)
비즈니스 리팩토링이나 클래스 고도화 과정에서 미세하게 발생하는 비즈니스 논리 소거 현상을 테스트 장벽이 어떻게 탐지하고 격리시키는지에 대한 도해 기법이다.

5. 실무 팁 및 주의사항 (Tips & Notes)
멱등성 및 탈퇴 순서 테스트를 누락할 때 발생하는 실제 장애 상황
- 순서가 보장되지 않은 탈퇴 테스트의 치명적 한계:
- 회원 탈퇴의 수명주기 서비스(UserWithdrawService.withdraw)를 검증하는 테스트 코드를 짤 때 단순히 userRepository.delete(user)가 실행되었는지만 단정하고 넘어가면 안 된다.
- 실제 대용량 트래픽이 쏟아지는 라이브 서버에서는 부모 객체가 먼저 소거되면서 DB 외래키 위반 예외가 터져 탈퇴가 부분 실패하는 연쇄 오류가 빈번하다. 이를 방어하려면 아래의 Mockito 인터페이스처럼 InOrder 객체를 소환하여 자식 삭제 레포지토리와 부모 삭제 레포지토리의 실행 물리 순서가 어긋남이 없는지를 철저하게 테스트로 박제해야 한다.
- InOrder를 통한 원자적 소거 스케줄 순서 강제 박제 기법
InOrder inOrder = inOrder(recordCommentRepository, recordRepository, userRepository);
inOrder.verify(recordCommentRepository).deleteAllByRecordIn(anyList()); // 1. 자식 선삭제
inOrder.verify(recordRepository).deleteAll(anyList()); // 2. 독립 콘텐츠 본문 파괴
inOrder.verify(userRepository).delete(any(User.class)); // 3. 부모 최종 제거
- 이벤트 가드 테스트 시 Mock 주입 트랩(Trap):
- Mockito는 행위 검증을 하기 좋은 도구이지만, @Mock 필드들이 실제 DB에 트랜잭션 커밋이 일어나는 순간까지 모사하진 않는다.
- 데이터 무결성이 커밋 완료된 안전 지대(AFTER_COMMIT)에서 이벤트를 가동하는 @TransactionalEventListener의 동작을 테스트하려면, 격리 단위 테스트에만 의존하지 말고 실제 임베디드 H2 DB를 올리고 @SpringBootTest 컨텍스트를 활용하는 통합 테스트 레이어에서 이벤트 리스너 동작 여부를 최종 검증하는 수순을 반드시 걸쳐야 한다.
6. 마무리 (Conclusion)
테스트 코드는 단순한 기능 검증 도구를 넘어 비즈니스를 지켜내는 "정책의 서면 계약서(Executable Contract)"다. 사용자 도메인을 설계하는 단계부터 금지 상황과 멱등성 조건, 그리고 인가 예외를 박제해 두면, 시스템이 아무리 복잡하게 확장되고 커지더라도 초기 단계에 정립해 둔 상호작용의 규칙들을 손실 없이 사수해 낼 수 있다.
7. 복습 질문과 명쾌한 해답 (Review Q&A)
Q1. 왜 사용자 도메인을 보호할 때는 단순 성공 케이스보다 예외를 뱉어내는 '실패 케이스 테스트'가 아키텍처 수립 관점에서 훨씬 더 중요한가?
A1. 성공 케이스는 비즈니스가 순조롭게 풀릴 때 데이터를 DB에 안착시키는 "해피 패스(Happy Path)"에 불과하지만, 사용자 도메인의 안전을 책임지는 핵심 가치는 "스스로를 차단한 자의 접근을 막고, 가입이 덜 끝난 사용자를 격리하는 네거티브 제한 정책"에 있기 때문이다. 실패 테스트를 최우선으로 촘촘히 엮어두지 않으면, 코드 수정 시점에 비즈니스 조건이 유실되더라도 서비스가 무중단으로 동작하기 때문에(단지 부정한 요청이 200 OK로 성공할 뿐) 정합성이 파괴된 상태를 조기에 잡아낼 길이 없어진다. 따라서 실패 테스트는 규칙 파괴를 알려주는 유일한 보안 경보기 역할을 수행한다.
Q2. 팔로우 요청의 멱등성(Idempotent) 처리(즉, 중복 시 no-op return) 정책을 테스트로 고정해야 하는 구체적인 이유는 무엇인가?
A2. 네트워크 전송 문제나 다중 중복 터치로 인해 중복 API 요청이 진입했을 때, DB 유니크 제약조건 충돌 예외를 뱉으며 트랜잭션이 롤백되는 부작용을 사전에 차단하기 위함이다. 멱등 검증 테스트가 없다면 리팩토링 시점에 중복 확인 로직이 누락되어도 일반 팔로우는 성공하므로 배포 전까지 오작동을 인지할 수 없다. 테스트로 고정해두면 중복 요청 유입 시 어떠한 가용 저장 메서드(save)도 연쇄 트리거 되지 않고 무반응 성공(no-op)으로 우아하게 빠져나가는 무결성 경로를 영구히 보장받을 수 있다.
Q3. 서비스 단위 테스트와 시큐리티 통합 슬라이스 테스트를 명확하게 이원화하여 격리하지 않고 하나로 뭉뚱그리면 어떤 리스크가 따르는가?
A3. 피드백 피로도 증가로 인한 테스트 생산성 파괴와 테스트 목적의 혼선이 발생한다. 스프링 컨테이너와 시큐리티 필터를 통째로 띄우는 통합 테스트로만 모든 비즈니스 예외(자기자신 팔로우, 차단 등)를 검증하려 하면, 테스트를 단 한 번 돌릴 때마다 수 초에서 수십 초의 로딩 지연이 걸려 개발 흐름이 완전히 끊긴다. 또한 특정 비즈니스 밸리데이션 검증 중 실패가 났을 때, 원인이 JPA 영속성 오류 때문인지 스프링 시큐리티 필터의 설정이 틀려서인지 예외의 인과관계를 추적하기가 극도로 난해해진다. 자바 코드 수준의 논리 예외는 단위 테스트로 0초 만에 끝내고, 필터와 401/403 응답 통신 정합성만 통합 테스트로 위임하는 격리 설계가 정석이다.
Q4. 회원 탈퇴 시 연관 데이터의 "정리 순서(Order of Cascading Deletion)"를 verify로 고정하여 테스트하지 않으면 실제 운영 중에 어떤 끔찍한 장애가 일어날 수 있는가?
A4. Foreign Key 제약 조건을 위반하는 DB 셧다운 오류가 발생하여 유저의 탈퇴 처리가 영구히 막히는 비정상 장애가 터진다. 탈퇴자의 전시 기록(부모)을 물리적으로 지우려 시도할 때, 해당 기록에 다른 유저가 남겨둔 댓글이나 좋아요(자식) 데이터가 먼저 디스크에서 제거되지 않았다면 RDBMS 엔진은 참조 정합성 수호를 위해 탈퇴 연산 자체를 강제 거부하고 커넥션을 끊어버린다. 이를 검증하는 순서 보장형 테스트(InOrder)가 없다면 개발자가 리팩토링 시 코드를 한 줄만 아래위로 뒤틀어도 릴리즈 직후 모든 탈퇴가 에러를 내며 실패하게 된다.
'Spring > Common' 카테고리의 다른 글
| [Spring] 스프링 메세지와 국제화 정리 (0) | 2026.05.24 |
|---|---|
| [Spring] 차단을 활용한 트랙잭션 경계 설계와 웹훅 결합시 마주하는 장애 분석 (0) | 2026.05.22 |
| [Spring] 소셜 관계 도메인 : '제약 기반 관계 정책' 설계 (1) | 2026.05.21 |
| [Spring] 엔티티를 단순 테이블 매핑이 아닌 '정책 상태 머신'으로 설계해야 하는 이유 (0) | 2026.05.21 |
| [Spring] 빈 생명주기 콜백: 애플리케이션의 시작과 종료 관리 (0) | 2026.05.10 |