배경
레벨 0 요구사항은 "프로젝트를 실행했으나 특정 에러로 인해 실행에 실패했다, 원인을 분석해서 실행 가능하게 만들어라(에러는 여러 개일 수 있다)"였다. 실제로 처음 받은 프로젝트를 그대로 실행하면 최소 두 가지 지점에서 막혔다.
문제 1. JWT 시크릿 키 미설정
JwtUtil에서 시크릿 키를 다음과 같이 외부 설정값으로 받고 있었다.
@Value("${jwt.secret.key}")
private String secretKey;
프로젝트에는 application.yml 자체가 없어서 jwt.secret.key 값을 어디서도 읽을 수 없었고, 애플리케이션이 뜨는 시점에 실패했다.
문제 2. MySQL 포트 충돌
application.yml을 작성하면서 MySQL 연결을 시도했는데, 3306 포트를 다른 프로그램이 이미 사용하고 있어서 연결이 되지 않았다. 해당 포트를 점유하고 있던 프로세스를 종료한 뒤 정상적으로 연결됐다.
해결
application.yml을 새로 작성해서 두 문제를 함께 해결했다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/advanced
username: root
password: 12345678
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create
show-sql: true
jwt:
secret:
key: abcdefghijklmnopqrstuvwxyz12345678901234567890
검증
@Value("${jwt.secret.key}")처럼 기본값이 없는 플레이스홀더는 Spring Boot가 자동 등록하는 PropertySourcesPlaceholderConfigurer가 해석한다.
공식 문서 기준으로 이 컴포넌트는 기본적으로 해석되지 않는 플레이스홀더가 있으면 실패하도록 되어 있고
(setIgnoreUnresolvablePlaceholders(true)를 명시적으로 켜야 실패 없이 넘어간다),
@Value가 참조하는 값에 기본값(${key:default})이 없으면 반드시 Environment에 그 값이 존재해야 한다.
즉 jwt.secret.key처럼 기본값 없는 필수값이 비어 있으면 컨텍스트 초기화 시점에 바로 실패하는 게 맞는 동작이었다.
(참고: 이 과정에서 src/main/resources 폴더가 IntelliJ에서 리소스 루트로 인식되지 않고 application.yml도 제대로 인식되지 않는 현상이 있었다. build.gradle에 spring-boot-devtools를 추가한 직후 해결됐지만, devtools 자체가 원인은 아닌 것으로 보인다 — build.gradle을 수정하면 IntelliJ가 Gradle 프로젝트를 다시 동기화하는데, 그 타이밍에 우연히 같이 인식된 것으로 추정된다.)
결과
application.yml 작성(DB 접속 정보, JPA 설정, JWT 시크릿 키)과 포트 정리로 프로젝트가 정상적으로 실행되는 상태를 만들었다. build.gradle에는 spring-boot-devtools도 함께 추가했다.
[Spring MVC] AuthUserArgumentResolver가 동작하지 않던 이유 - ArgumentResolver 등록
문제
AuthUserArgumentResolver의 supportsParameter()/resolveArgument() 로직 자체는 정상이었지만, @Auth AuthUser 파라미터를 쓰는 모든 컨트롤러가 동작하지 않고 있었다.
검증
Spring MVC는 HandlerMethodArgumentResolver를 구현한 클래스가 있다고 자동으로 인식해서 써주지 않는다. RequestMappingHandlerAdapter는 기본으로 정해진 리졸버 목록만 갖고 있고, 커스텀 리졸버를 그 목록에 추가하려면 WebMvcConfigurer.addArgumentResolvers()를 통해 명시적으로 등록해야 한다.
(Spring 공식 문서: "Custom argument resolvers can be implemented to support unique argument types not covered by the default set" — WebMvcConfigurer가 이 등록을 위한 확장 지점이다). 즉 리졸버 로직 자체가 맞아도, 등록 코드가 없으면 그 리졸버는 존재하지 않는 것과 같다.
해결
WebConfig implements WebMvcConfigurer를 새로 만들고 addArgumentResolvers()에서 등록했다. AuthUserArgumentResolver엔 @Component @RequiredArgsConstructor를 붙여 스프링 빈으로 만들고, WebConfig에서 생성자 주입으로 받아 등록했다.
@Component
@RequiredArgsConstructor
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { ... }
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthUserArgumentResolver argumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(argumentResolver);
}
}
다른 방식은 없었는지
리졸버를 빈으로 만들지 않고 WebConfig 안에서 new AuthUserArgumentResolver()로 직접 생성해서 등록하는 방법도 있다. 지금은 AuthUserArgumentResolver가 다른 빈에 의존하지 않아서 두 방식의 동작 차이는 없다.
다만 나중에 이 리졸버가 다른 빈(예: 별도 인증 유틸)을 주입받아야 하는 상황이 오면 new 방식은 코드를 고쳐야 하고, 빈으로 등록해둔 쪽은 그대로 확장된다 — 그래서 빈 등록 방식을 선택했다.
[Spring] 코드 개선 3종 - Early Return, if-else 제거, Validation 위치 이동
2-1. Early Return
문제: signup()에서 이메일 중복 여부(existsByEmail)를 확인하기도 전에 passwordEncoder.encode()부터 호출하고 있었다. encode()는 BCrypt 기반이라 비교적 비용이 큰 연산인데, 이미 존재하는 이메일이라 어차피 실패할 요청에도 이 연산이 먼저 실행되고 있었다.
해결: 이메일 중복 체크를 encode() 호출보다 앞으로 옮겨서, 중복이면 그 자리에서 바로 예외를 던지고 리턴하도록 순서를 바꿨다.
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
2-2. 불필요한 if-else 제거
문제: getTodayWeather()에서 상태 코드가 OK가 아니면 예외를 던지고 메서드 흐름을 끝내는 if 블록인데도, 그 다음 null/length 체크가 else 블록 안에 중첩되어 있었다. if 쪽에서 이미 흐름이 끝나므로 else는 로직상 불필요한 중첩이었다.
해결: 상태 코드가 OK가 아니면 그 자리에서 예외를 던지고 리턴하므로, 이어지는 null/length 체크를 else 없이 나란히 작성해도 동일하게 동작한다는 점을 이용해 중첩을 제거했다.
2-3. 비밀번호 형식 검증을 서비스에서 DTO로 이동
문제: changePassword() 안에서 새 비밀번호 형식(8자 이상, 숫자·대문자 포함)을 length()/matches() 조합으로 서비스 로직 한가운데서 직접 검증하고 있었다. 입력 형식 검증은 요청이 들어오는 시점(DTO)에서 걸러지는 게 자연스러운데, 서비스 로직에 섞여 있어 책임이 분리되어 있지 않았다.
해결: 검증 로직을 @Pattern(regexp = "^(?=.*\\d)(?=.*[A-Z]).{8,}$")로 옮기고 컨트롤러에 @Valid를 추가했다. 같은 비밀번호 정책을 SignupRequest.password에도 적용해서, 회원가입 시점부터 동일한 정책이 걸리도록 범위를 넓혔다(요구사항은 changePassword()만 명시했지만, 비밀번호 정책이라면 회원가입에도 같이 적용되는 게 맞다고 판단).
@NotBlank(message = "새 비밀번호를 입력하세요")
@Pattern(
regexp = "^(?=.*\\d)(?=.*[A-Z]).{8,}$",
message = "새 비밀번호는 8자 이상이며, 숫자와 대문자를 포함해야 합니다."
)
private String newPassword;
검증 중 발견한 보완 필요 지점: @Valid 검증에 실패하면 스프링은 MethodArgumentNotValidException을 던진다. 그런데 지금 GlobalExceptionHandler엔 이 예외 전용 핸들러가 없고, 레벨 1에서 추가한 @ExceptionHandler(Exception.class) catch-all 핸들러만 있다.
MethodArgumentNotValidException은 InvalidRequestException도 AuthException도 아니라서 이 catch-all에 걸리게 되고, 그러면 의도한 400 + 구체적 검증 메시지 대신 500 "서버 오류"로 응답될 가능성이 높다. MethodArgumentNotValidException 전용 핸들러를 추가해서 400 응답과 필드 메시지를 유지하는 걸 권장한다 — 실제 요청을 보내서 응답을 확인해보는 게 좋을 것 같다.
[Spring Data JPA] N+1 문제 해결 - JPQL fetch join을 @EntityGraph로 전환
문제
TodoRepository.findAllByOrderByModifiedAtDesc()는 JPQL "LEFT JOIN FETCH t.user u"로 N+1 문제를 이미 해결하고 있었다. 요구사항은 이 방식을 메서드 이름 기반 쿼리 + @EntityGraph로 바꾸는 것이었다.
해결
@Query JPQL을 제거하고, 메서드 이름만으로 쿼리가 생성되는 findAllByOrderByModifiedAtDesc(Pageable pageable)에 @EntityGraph(attributePaths = {"user"})를 붙였다. 여기서 더 나아가, 같은 파일에 있던 또 다른 fetch join 메서드 findByIdWithUser()도 없애고, JpaRepository가 기본 제공하는 findById()를 @Override + @EntityGraph로 재정의해서 TodoService.getTodo()가 표준 findById()를 쓰도록 통일했다.
@EntityGraph(attributePaths = {"user"})
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);
@Override
@EntityGraph(attributePaths = {"user"})
Optional<Todo> findById(@NonNull Long id);
검증
Spring Data JPA 공식 문서에 따르면 @EntityGraph(attributePaths = {...})는 리포지토리가 기본 제공하는 메서드를 오버라이드할 때도 동일하게 적용된다 — 공식 예시로 findAll(Specification, Pageable)을 오버라이드하며 @EntityGraph를 붙이는 패턴이 문서에 그대로 나온다. 내부적으로 SimpleJpaRepository.applyQueryHints()를 통해 JPA fetch 힌트로 적용되므로, findById()를 오버라이드하는 것도 같은 방식으로 정상 동작한다.
다른 방식은 없었는지
요구사항 문장이 "N+1 문제가 발생할 수 있는 시나리오는 getTodos 메서드에서…"라고 특정 시나리오만 예로 들었기 때문에, findAllByOrderByModifiedAtDesc()만 @EntityGraph로 바꾸고 findByIdWithUser()는 그대로 둬도 요구사항 자체는 충족된다(실제로 그렇게만 처리해도 동작하는 걸 확인함). 리포지토리 전체를 하나의 방식으로 통일할지, 요구사항이 콕 집어 설명한 부분만 바꿀지는 범위를 얼마나 넓게 해석하느냐의 문제였고, 여기서는 일관성을 위해 전체를 통일하는 쪽을 선택했다.
다시 학습 - findById까지 바꾼 건 잘못된 선택이었다
findById()는 한 건만 조회하는 거라 애초에 "N+1"이 아니다 — N이 없다. @EntityGraph를 안 붙이면 user를 쓸 때 지연로딩으로 쿼리가 딱 1번 더 나가는 정도지, 목록 조회처럼 N번 반복되는 문제가 아니다. "엔티티그래프를 붙이면 무조건 더 빠르다"고 막연히 생각해서 findById()까지 확장했는데, 실제로 이 메서드를 쓰는 곳을 전부 확인해보니 그렇지 않았다.
findById()를 쓰는 곳은 5군데였고, 그중 user를 실제로 쓰는 곳은 3곳(TodoService.getTodo(), ManagerService.saveManager(), deleteManager())뿐이었다. 나머지 2곳(ManagerService.getManagers(), CommentService)은 user를 아예 안 쓴다. Todo.user는 원래 LAZY라서, @EntityGraph가 없었다면 이 2곳은 애초에 추가 쿼리 자체가 안 나갔을 것이다(지연로딩은 실제로 쓸 때만 쿼리가 나가니까). 그런데 findById()에 @EntityGraph를 걸어버리면 user가 필요 없는 요청에도 매번 조인이 따라붙는다.
즉 이 선택은 "3곳에서 쿼리 1번씩 아끼려고, 2곳에서는 매번 안 써도 될 조인 비용을 지불하는" 트레이드오프였고, 애초에 findById() 하나당 아끼는 게 "쿼리 1번"에 불과하다는 걸 생각하면 이득보다 손해가 더 명확한 선택이었다. 더 나은 방법은 findById()는 원래대로(LAZY, @EntityGraph 없이) 두고, user가 필요한 3곳만 별도 메서드에 @EntityGraph를 걸어서 쓰는 것이었다.
[테스트] 테스트코드 연습 - 잘못된 테스트/버그 수정
4-1. PasswordEncoderTest
문제: PasswordEncoder.matches(rawPassword, encodedPassword) 순서로 정의되어 있는데, 테스트에서는 matches(encodedPassword, rawPassword)로 인자 순서가 뒤바뀐 채 호출되고 있었다. 실제 구현이 맞더라도 테스트는 항상 실패하는 상태였다.
해결: 인자 순서를 실제 메서드 시그니처에 맞게 고쳤다. 여기서 더 나아가 @InjectMocks와 SpringExtension 기반 목킹도 걷어내고 new PasswordEncoder()로 직접 생성하도록 바꿨다 — PasswordEncoder는 목으로 대체할 의존성이 없는 클래스라서, 목킹 프레임워크를 쓸 이유가 없었다.
4-2. ManagerServiceTest
문제 1: manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() 테스트가 InvalidRequestException의 메시지로 "Manager not found"를 기대하고 있었는데, 실제 getManagers()가 Todo를 못 찾을 때 던지는 메시지는 "Todo not found"였다.
해결: 메시지를 "Todo not found"로 수정했다.
문제 2: todo의_user가_null인_경우_예외가_발생한다() 테스트가 원래 기대한 대로, saveManager()는 todo.getUser()가 null일 때 getId() 호출에서 실제로 NPE를 던지는 버그가 있었다. deleteManager()에는 이미 있던 null 체크가 saveManager()에는 빠져 있었다.
해결: deleteManager()와 동일한 null 체크(todo.getUser() == null || ...)를 saveManager()에도 추가했다.
4-3. CommentServiceTest
문제: 테스트는 Todo가 없을 때 ServerException이 던져지길 기대했지만, 실제 saveComment()는 InvalidRequestException("Todo not found")를 던지고 있어서 테스트가 실패했다.
해결: 테스트가 검증하는 예외 타입을 실제 동작에 맞게 수정했다.
[Spring] 검증 실패 메시지, 클라이언트에 어떻게 전달할 것인가
배경
레벨 2-3에서 @Valid/@Pattern 검증을 추가한 뒤, 검증에 실패하면 클라이언트에게 정확히 어떤 정보를 어떤 형태로 돌려줄지 고민이 생겼다. @Valid가 걸린 DTO는 필드가 여러 개라, 한 번에 여러 필드가 동시에 실패할 수도 있다.
시도 1: 예외 메시지를 그대로 사용
MethodArgumentNotValidException.getMessage()를 그대로 쓰면 사람이 읽을 메시지가 아니라 스프링 내부 디버깅용 텍스트가 나온다.
Validation failed for argument [0] in public ... : [Field error in object 'signupRequest' on field 'password': rejected value [A1]; codes [Pattern.signupRequest.password, ...]; ... default message [새 비밀번호는 8자 이상이며, 숫자와 대문자를 포함해야 합니다.]]
@Pattern(message = "...")에 직접 적어둔 메시지는 이 안에 파묻혀 있어서, 그대로 응답에 쓰기엔 부적합하다.
시도 2: 필드 에러 하나만 꺼내기 (단수)
FieldError fieldError = ex.getBindingResult().getFieldError();
String message = fieldError != null ? fieldError.getDefaultMessage() : "잘못된 요청입니다.";
필드 하나만 실패하면 문제없이 그 메시지가 나간다. 하지만 이메일 형식과 비밀번호 정책을 동시에 어겨서 두 필드가 같이 실패하는 요청으로 테스트해보니, 매번 같은 필드가 아니라 그때그때 다른 필드의 메시지가 나왔다.
검증: 왜 결과가 매번 다르게 나오는가
Jakarta Bean Validation 표준의 Validator.validate()는 검증 결과를 List가 아니라 Set<ConstraintViolation<T>>로 반환한다(Hibernate Validator 공식 문서의 예제들이 전부 이 타입을 쓴다).
Set은 순서를 보장하지 않는 자료구조라서, 여러 필드가 동시에 실패했을 때 그중 뭐가 먼저 나올지는 애초에 정해진 규칙이 없다. 필드 선언 순서도 아니고 검증 애노테이션을 적은 순서도 아니다 — "그때그때 다르게 나온다"는 관찰이 실제로 근거가 있는 현상이었다.
시도 3: 필드별로 구조화한 배열
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> Map.of(
"field", error.getField(),
"message", error.getDefaultMessage()
))
.toList();
응답을 "errors": [{"field": "email", "message": "..."}, {"field": "password", "message": "..."}] 형태로 만든다. 문자열을 합치는 대신 필드-메시지 쌍의 배열로 응답하는 것이라, 이 응답을 소비하는 쪽(프론트엔드, 다른 서비스, 테스트 코드 등)이 특정 필드의 에러를 바로 꺼내 쓸 수 있다.
실무에서 검증 에러 응답에 이런 구조화된 형태를 더 흔히 쓰는데, 프론트가 입력창마다 에러를 표시하기 편하다는 것 외에도 API 문서화(정확한 스키마 명시)와 테스트 코드(errors.get(0).getField()처럼 값으로 직접 검증) 양쪽에서 이점이 있다.
결과
우선 시도 2(단수) 방식으로 커밋했고, 이후 시도 3 방식도 별도로 작성해봤다. 지금 과제 범위에서는 어느 쪽이든 요구사항(비밀번호 정책 위반 시 적절한 메시지 반환)은 충족하지만, 여러 필드가 동시에 실패하는 경우까지 고려하면 시도 3이 더 안전하다.
[Spring] 예외 메시지를 ErrorCode enum으로 옮기며 깨달은 것
배경
InvalidRequestException/AuthException/ServerException 3개 클래스에 문자열 메시지를 그때그때 넘겨서 예외를 던지고 있었는데, 같은 의미의 에러("Todo not found" 등)가 여러 파일에 중복 하드코딩돼 있는 걸 발견했다. ErrorCode enum과 CustomException 하나로 통합하는 리팩토링을 진행했다 — 상태코드 구분 역할을 예외 클래스가 아니라 ErrorCode가 담당하도록 바꾼 것이다.
과정에서 겪은 것
throw 지점을 하나씩 옮기는 과정에서 실수가 여러 번 났다.
- getErrorResponse()에 오버로드를 추가하는 대신 기존 메서드를 교체해버려서, 그걸 쓰던 다른 핸들러들이 전부 컴파일 에러가 남
- 캐치올 핸들러(Exception.class)를 수정하다가 메서드 자체가 통째로 사라짐 — 트레이스 노출을 막으려고 처음에 만든 안전장치가 리팩토링 도중 없어져서 당황함
- new 키워드를 빠뜨려서 생성자 호출이 (존재하지 않는) 메서드 호출로 오인됨
- 예외 3개를 하나로 합치는 과정에서, 원래 서로 다른 상태코드였던 두 곳(로그인 비밀번호 실패 401, 비밀번호 변경 실패 400)이 같은 ErrorCode로 합쳐지면서 상태코드가 의도치 않게 바뀜
깨달은 점
이미 짜여진 로직에 나중에 enum 설계를 끼워 넣으려니 번거로웠다. 처음부터 enum으로 설계했다면 이런 시행착오는 없었을 것이다. 그래서 "자주 재사용되는 메시지만 enum으로 만들고, 한 번만 쓰이는 메시지는 그냥 문자열로 두는 게 낫지 않았을까"라는 생각이 들었다.
다만 이 판단에는 반론도 있다 — 예외를 던지는 방식이 enum 기반과 문자열 직접 던지기 두 갈래로 나뉘면, 새 예외를 추가할 때마다 "이게 enum으로 만들 만큼 자주 쓰일까"를 매번 판단해야 하고, 지금 한 번만 쓰이는 메시지도 나중에 재사용되면 결국 다시 지금과 같은 리팩토링을 반복해야 한다.
절충안으로, CustomException에 ErrorCode를 받는 생성자 말고 (HttpStatus, String message)를 즉석으로 받는 생성자를 하나 더 두는 방법도 있다. 예외를 던지는 방식(CustomException) 자체는 통일하면서, 정말 한 번만 쓰이는 메시지는 enum 상수로 안 만들고 그 자리에서 바로 넘길 수 있다.
[Spring] Interceptor와 AOP, 역할을 안 겹치게 나누기
배경
레벨5(선택) 요구사항은 어드민 전용 API(CommentAdminController.deleteComment(), UserAdminController.changeUserRole()) 접근 시 Interceptor로 권한을 확인하고, AOP로 요청/응답을 상세히 로깅하는 것이었다.
Interceptor - 처음 구현과 그 이후
요구사항대로 Interceptor에 "어드민 아니면 막기 + 접근 로깅"을 그대로 구현했는데, 코드를 보다가 JwtFilter가 이미 /admin으로 시작하는 요청에 대해 어드민 권한을 검사하고 있는 걸 발견했다.
if (url.startsWith("/admin") && !UserRole.ADMIN.equals(userRole)) {
sendErrorResponse(httpResponse, HttpStatus.FORBIDDEN, "접근 권한이 없습니다.");
return;
}
즉 Interceptor의 권한 체크는 실제로는 도달할 일이 없는 중복 코드였다. 다만 JwtFilter를 다시 보니, 거부했을 때만 로그를 남기고 통과시킬 때는 아무 기록도 안 남기고 있었다 .
chain.doFilter()만 호출하고 끝난다. 그래서 Interceptor의 역할을 "권한 재확인"에서 "어드민이 실제로 어떤 API를 건드렸는지 기록하는 감사 로그"로 좁혔다. 권한 검사와 무관하게, 통과된 이후의 행동 자체를 기록에 남긴다는 점에서 여전히 의미가 있었다.
@Slf4j
@Component
public class AdminAccessInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("[관리자 접근] userId={}, URI={}, 시각={}",
request.getAttribute("userId"), request.getRequestURI(), LocalDateTime.now());
return true;
}
}
AOP - 성능 측정으로 방향 전환
레벨5는 "정해진 정답은 없습니다, 자신만의 생각을 코드에 담아보세요"라는 항목이라, AOP는 요구사항이 말한 "어드민 API 요청/응답 바디 로깅" 대신 레벨3에서 다룬 N+1 문제를 실제로 증명하는 데 쓰기로 했다. TodoService.getTodos()(N+1이 있었던 그 메서드)를 @Around로 감싸서 실행시간을 측정한다.
@Around("execution(* org.example.expert.domain.todo.service.TodoService.getTodos(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
log.info("[성능 측정] {} 실행시간: {}ms", joinPoint.getSignature().toShortString(), elapsed);
return result;
}
@EntityGraph를 껐다 켰다 하면서 같은 요청을 두 번 호출해보면, N+1이 있을 때와 없을 때의 실행시간을 직접 비교할 수 있다.
결과
최종적으로 세 가지가 역할을 안 겹치게 나뉘었다.
- JwtFilter: 권한 차단(거부만 기록)
- AdminAccessInterceptor: 통과된 어드민 접근의 감사 로그(누가/어떤 URI/언제)
- LoggingAspect: TodoService.getTodos()의 실행시간 측정(N+1 성능 비교용)
[회고] 테스트 코드와 Mockito에 대한 생각
레벨4에서 테스트 코드를 고치다 보니, 테스트 코드 자체에 대한 실무 관행도 같이 정리해본다.
테스트 코드가 실제로 주는 이점
- 예상치 못한 부작용 발견: 어떤 비즈니스 로직을 수정했을 때, 그 변경이 전혀 다른 곳에 영향을 미치는 경우가 있다. 기존 테스트 코드가 있으면 이런 부작용이 있는지 실행해보는 즉시 알 수 있다 — 사람이 코드를 읽으면서 "이거 건드리면 저기도 영향 있겠다"를 매번 다 예측하는 건 현실적으로 어렵다.
- 리팩토링을 안심하고 할 수 있게 해줌: 테스트가 충분히 있으면, 내부 구현을 바꾸거나 코드를 정리해도 "겉보기 동작이 그대로인지"를 테스트가 바로 확인해준다. 테스트가 없으면 리팩토링 자체가 위험한 일이 되어서, 다들 손대기를 꺼리게 된다.
- 실행 가능한 문서 역할: 잘 짜인 테스트는 "이 코드가 이런 입력에서 이렇게 동작해야 한다"를 코드로 보여준다. 주석이나 문서는 시간이 지나면 실제 코드와 어긋나도 아무도 모르지만, 테스트는 실제 동작과 어긋나면 바로 실패하기 때문에 계속 최신 상태로 유지된다.
- 버그를 더 일찍, 더 싸게 발견: 개발 중에 테스트로 잡히는 버그는 고치는 비용이 작지만, 같은 버그를 배포 후 사용자가 발견하면 훨씬 큰 비용(장애 대응, 신뢰 하락 등)이 든다.
회사마다 다른 테스트 문화
- 회사마다 테스트 코드를 아예 안 쓰는 곳도 있다. 특히 초기 스타트업이나 레거시 코드베이스에 흔하다.
- 어떤 로직을 수정했는데 테스트 코드가 통과 안 되면 PR을 올려도 리뷰조차 하지 않는 문화가 있다 — 테스트도 통과 못 한 코드를 리뷰하는 건 시간 낭비이기 때문이다.
- 기획자의 요구대로 로직을 수정했는데 기존 테스트 코드에 걸린다면, 그냥 테스트를 고치기 전에 기획자와 의논해야 한다. 예상치 못한 영향이 생긴 것일 수 있고, 이런 상황을 애초에 고려하지 않은 기획이었을 수도 있기 때문이다.
- 실무에서는 PR을 올리면 GitHub Actions 같은 CI 설정으로 테스트가 자동 실행되고, 통과해야 머지되는 방식을 많이 쓴다.
Mockito를 지양하는 문화도 있다 - 근데 이유를 정확히 알아야 한다
Mockito 같은 목킹 프레임워크를 과하게 쓰는 걸 지양하는 문화가 실제로 있다고 한다. 처음엔 "Mockito가 가짜 데이터를 지멋대로 넣어줘서 못 믿는다"는 식으로 이해했는데, 이건 정확한 이해가 아니었다.
Mockito 자체는 완전히 결정적(deterministic)이다 — when(...).thenReturn(...)으로 정해준 대로만 동작하지, 랜덤하게 움직이지 않는다. 실제 문제는 다른 데 있다.
목(mock)이 반환하는 값은 "진짜 의존성이 이렇게 동작할 것이다"라고 개발자가 가정해서 만든 가짜값인데, 그 가정이 처음부터 틀렸거나, 나중에 실제 코드가 바뀌었는데 목은 그대로 남아있으면, 테스트는 계속 통과하는데 실제 서비스는 안 되는 상황이 생긴다.
즉 문제는 "Mockito가 지멋대로"가 아니라 "목이 실제 동작과 점점 어긋나도 테스트만 보고는 아무도 눈치채지 못한다"는 것이다.
이 논쟁은 테스트 커뮤니티에서 "Mockist vs Classicist"(또는 London school vs Chicago/Detroit school) 논쟁으로 불리고, Martin Fowler의 "Mocks Aren't Stubs" 글이 자주 인용된다. 목을 최소화하고 실제 의존성(또는 Testcontainers 같은 걸로 띄운 진짜 DB)을 쓰는 걸 선호하는 진영과, 단위를 확실히 격리하기 위해 목을 적극적으로 쓰는 진영이 나뉘어 있다.
'TIL' 카테고리의 다른 글
| GitHub Actions와 AWS OIDC 기반 CI/CD 구축 및 인증 오류 해결 (0) | 2026.08.01 |
|---|