과제 한 줄 요약
Spring Boot 일정 관리 서비스의 "고장난 프로젝트"를 받아, 에러 진단 → 코드 개선 → 성능 최적화 → 테스트 복구까지 수행하는 유지보수형 과제
레벨별 요약 🔽
Lv 0. 에러 분석 — 실행조차 안 되는 프로젝트를 진단. jwt.secret.key 미설정으로 인한 빈 생성 연쇄 실패와 DB 접속 실패(Dialect 결정 불가) 두 가지 에러를 분석하고 설정 파일 작성으로 해결.
Lv 1. ArgumentResolver — 구현체는 있지만 등록이 삭제된 AuthUserArgumentResolver를 WebConfig(WebMvcConfigurer)에 등록해 @Auth AuthUser 파라미터 주입 복구.
Lv 2. 코드 개선 (3종)
- Early Return: signup의 이메일 중복 검증을 고비용 연산인 encode() 앞으로 이동
- if-else 제거: throw로 끝나는 분기의 불필요한 else를 없애 가드절로 평탄화
- Validation: 서비스의 비밀번호 형식 검증을 DTO의 Bean Validation(@Pattern + @Valid)으로 이관
Lv 3. N+1 해결 — JPQL fetch join으로 해결돼 있던 것을 @EntityGraph(attributePaths = {"user"}) 기반으로 전환. findById를 오버라이드해 동일한 조인 로딩 동작 구현.
Lv 4. 테스트 코드 (4건) — Given-When-Then 패턴 하에서:
- 인자 순서가 뒤집힌 테스트 수정 (PasswordEncoder)
- 실제 예외와 다른 타입을 기대하던 테스트 2건 수정 (InvalidRequestException으로)
- 테스트가 스펙인 케이스는 반대로 서비스 로직에 null 체크를 추가해 NPE 대신 의도된 예외 발생하도록 수정
핵심 학습 키워드📍
빈 생성 연쇄 실패 분석 · ArgumentResolver 등록 · 가드절(Early Return) · Bean Validation · @EntityGraph · 쿼리 메서드 오버라이드 · Mockito 단위 테스트 · 단락 평가
오늘 한 것 📒
- Lv 0. 실행조차 안 되는 프로젝트 에러 분석하고 살려내기
- Lv 1. AuthUserArgumentResolver 동작 복구
- Lv 2. Early Return, 불필요한 if-else 제거, Validation DTO로 옮기기
- Lv 3. N+1 문제를 fetch join → @EntityGraph로 전환
- Lv 4. 테스트 코드 4건 수정 (단위 테스트 / 서비스 로직 수정)
배운 것 ✍️
1. 스프링 빈 생성 실패는 "연쇄"로 터진다
에러 로그의 첫인상은 filterConfig 생성 실패였지만, 진짜 원인은 그 안쪽에 있었습니다.
Error creating bean with name 'filterConfig'
└─ Error creating bean with name 'jwtUtil'
└─ Injection of autowired dependencies failed
JwtUtil이 @Value("${jwt.secret.key}")로 값을 받아야 하는데
설정 파일이 없어서 실패 → JwtUtil을 쓰는 FilterConfig도 같이 실패 → 앱 전체가 안 뜨는 구조였습니다...
에러 로그는 맨 위부터 읽는 게 아니라 가장 안쪽 원인(마지막 Caused by:)부터 읽어야 한다는 걸 몸으로 배웠습니다.
2. Hibernate의 "Unable to determine Dialect"는 사실 DB 접속 실패다
메시지만 보면 Dialect 설정 문제 같은데, Hibernate는 DB에 실제로 접속해서 메타데이터를 읽은 뒤 Dialect를 자동으로 정해요.
그래서 접속 자체가 안 되면 이 에러가 뜨는 거였습니다.
datasource 설정 넣고 CREATE DATABASE expert; 하니까 해결완료~!!!
3. ArgumentResolver는 구현만으로는 동작하지 않는다
HandlerMethodArgumentResolver 구현체가 멀쩡히 있어도, 스프링한테 "이거 써줘"라고 알려주지 않으면 그냥 없는 취급이에요.
WebConfig를 만들어서 등록하니까 @Auth AuthUser 주입이 바로 됐습니다~!
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthUserArgumentResolver());
}
}
4. Early Return은 "비싼 연산을 아끼는" 실전 기법이다
BCrypt의 encode()는 일부러 느리게 만든 고비용 연산인데,이메일이 이미 중복이면 어차피 예외로 끝나요.
그런데 인코딩을 먼저 하면? 매번 버려질 연산을 하고 있는 셈이죠.
검증을 맨 위로 올려서(가드절) 실패할 요청은 바로 리턴되게 고쳤습니다.
5. Validation은 DTO에서 - 관심사 분리
서비스 안의 비밀번호 형식 검증 if문을 DTO의 Bean Validation으로 옮겼습니다.
@Pattern(
regexp = "^(?=.*\\d)(?=.*[A-Z]).{8,}$",
message = "새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다."
)
private String newPassword;
- 컨트롤러 바인딩 시점(@Valid)에 잘못된 입력을 차단 → 비즈니스 로직에 도달하기 전에 걸러짐
- 검증 규칙이 선언적으로 보여서 가독성/재사용성이 좋아짐
- 덤으로, 기존 코드의 matches("ㅅ")는 숫자 검증이 아예 동작하지 않던 버그였다는 것도 발견!
6. @EntityGraph = 선언적 fetch join
@EntityGraph(attributePaths = {"user"})를 붙이면 조회할 때 user를 LEFT JOIN으로 같이 들고 와요.
JPQL 없이도 fetch join과 똑같이 N+1을 방지할 수 있습니다.
적용하고 SQL 로그를 보니까 join 포함 쿼리 1방으로 나가는 걸 확인했어요.
7. 테스트가 실패하는 이유는 "코드가 틀려서"일 수도, "테스트가 틀려서"일 수도 있다
Lv 4를 하면서 양쪽 다 겪어봤어요.
- 테스트가 틀린 케이스:
matches() 인자 순서 뒤집힘, 실제와 다른 예외 타입 기대 (ServerException인 줄 알았는데 실제로는 InvalidRequestException) - 서비스가 틀린 케이스:
todo.getUser()가 null이면 NPE가 터지는 로직 → null 체크를 추가해서 의도된 예외가 나가게 수정
테스트를 고칠 땐 실제 서비스가 어떤 예외를 어떤 메시지로 던지는지 먼저 확인하고 맞추는 게 순서예요.
그런데 3번 케이스처럼 반대로 테스트가 스펙이고 서비스를 고쳐야 하는 경우도 있어서, "누가 맞는지"부터 판단해야 하더라고요.
트러블슈팅💥
1) resources 폴더가 아예 없었다
상황:
jwt.secret.key를 넣으려고 봤더니 src/main/resources 폴더 자체가 없었어요..
보통 과제 프로젝트엔 설정 파일이 기본으로 있는데, 이건 에러 분석 과제라 일부러 빼놓은 거였습니다.
해결:
src/main에 resources 디렉토리를 만들고 (IntelliJ가 Gradle 표준 경로라서 Resources Root로 알아서 잡아줘요) application.properties를 만들어 JWT 키와 datasource 설정을 작성했습니다.
배운 점:
개인적으로 파일 내용이 틀린 것보다 파일이 "없는" 게 훨씬 찾기 어려웠습니다..
src/main/resources는 클래스패스에 포함되는 약속된 경로라는 것도 다시 확인했습니다.
2) git add 했는데 nothing to commit?? — .gitignore의 함정
상황:
application.properties를 만들고 git add 후 커밋했는데
nothing to commit, working tree clean이 떴어요. 분명 새 파일인데 git이 못 보는 상황.
원인:
git check-ignore -v src/main/resources/application.properties
# .gitignore:40:*.properties src/main/resources/application.properties
.gitignore 40번째 줄의 *.properties 규칙이 범인이었어요.
DB 비밀번호 같은 게 원격에 올라가는 걸 막으려는 규칙이었습니다.
해결:
비밀번호가 든 파일을 -f로 억지로 올리는 대신,
- 단계별 커밋 기록은 git commit --allow-empty로 남기고
- 비밀번호를 뺀 application-example.properties만 저장소에 포함시켰어요.
배운 점:
git check-ignore -v를 쓰면 어떤 규칙이 막고 있는지 바로 나와요.
그리고 gitignore가 막는 데는 다 이유가 있으니, 우회하기 전에 "이거 올려도 되는 파일 맞나?"부터 생각하기.
3) Postman 401 → 500 → 401, 3연타
상황 1 (401):
토큰을 넣었는데 계속 "인증이 필요합니다".
알고 보니 Headers에서 Key 칸은 비워두고 Value 칸에 Authorization: Bearer ...를 통째로 넣고 있었어요....
이러면 Postman이 그 헤더를 아예 전송하지 않습니다.
상황 2 (500):
Key/Value를 나눠 넣었는데 이번엔 500 + 서버 로그에 ServerException: Not Found Token.
여전히 헤더 형식이 잘못돼서 서버가 토큰을 못 찾은 거였어요.
상황 3 (또 401):
잘 쓰던 토큰인데 갑자기 401. JWT 유효시간(60분)이 지나서 토큰 만료였습니다ㅠㅠ
/auth/signin으로 재로그인해서 새 토큰을 받아 해결했어요.
배운 점:
- HTTP 헤더는 Key: Authorization / Value: Bearer {토큰} 으로 분리해서 넣기. 콜론은 클라이언트가 알아서 붙여줘요
- "아까는 됐는데 지금 안 되는" 401 → 1순위 의심은 토큰 만료
- 같은 401이어도 서버 로그의 예외 위치(JwtUtil.substringToken 등)를 봐야 진짜 원인을 알 수 있어요.
증상이 같다고 원인도 같은 게 아니라는점! 꼭 인식하기!!!
4) @EntityGraph로 바꾸다가 만난 쿼리 메서드 네이밍 문제
상황:
findByIdWithUser에서 @Query를 지우고 @EntityGraph만 붙이면 될 줄 알았는데, 이 이름이 문제였어요.
Spring Data JPA는 @Query가 없으면 메서드 이름을 쿼리로 해석하는데,
WithUser라는 필드는 엔티티에 없으니 파싱부터 실패합니다.
해결:
findById를 리포지토리에 재선언하면서 @EntityGraph를 붙여 기본 메서드를 오버라이드했어요.
그리고 findByIdWithUser 호출부는 Find Usages(Alt+F7)로 전부 찾아 findById로 교체했습니다.
@EntityGraph(attributePaths = {"user"})
Optional<Todo> findById(Long todoId);
배운 점:
- @Query가 없는 리포지토리 메서드는 이름이 곧 쿼리예요. 이름을 바꾸거나 지울 땐 파싱 규칙을 생각해야 합니다
- findById 같은 기본 메서드도 재선언하면 어노테이션으로 커스터마이징이 가능해요
- 이렇게 하면 다른 서비스에서 쓰던 findById에도 fetch 전략이 일괄 적용되는데, N+1 방지 관점에선 오히려 일관성이 생겨서 좋다고 판단했습니다
5) 컴파일 에러의 범인은 한/영 전환 오타
상황:
CommentServiceTest의 예외 타입을 고쳤는데 테스트 실행 자체가 안 되고 컴파일 에러가 났어요.
예외 타입을 잘못 고쳤나 한참 봤는데 범인은 딴 데 있었습니다.
@Mock두 // ← ??? 한/영 전환이 안 된 채로 타이핑하다 붙은 "두"
private CommentRepository commentRepository;
해결:
"두" 지우기. 끝.
배운 점:
컴파일 에러가 나면 "방금 내가 의도적으로 고친 부분"만 의심하게 되는데,
실제로는 의도치 않게 건드린 곳이 범인인 경우가 많아요.
에러가 가리키는 라인 번호를 선입견 없이 그대로 따라가는 게 제일 빠릅니다.
과제 원본 코드의 matches("ㅅ")도 그렇고 저도 그렇고.. 한/영 전환은 단순하면서도 놓치기 쉬운 적😠
6) NPE 방지는 null 체크 "순서"가 핵심
상황:
todo의_user가_null인_경우_예외가_발생한다 테스트가 실패했어요.
서비스가 todo.getUser().getId()를 바로 호출해서, user가 null이면 의도한 InvalidRequestException 대신 NPE가 터졌습니다.
해결:
if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
}
||는 앞이 true면 뒤를 평가하지 않으니까(단락 평가), null 체크를 무조건 앞에 둬야 뒤의 .getId() 호출이 안전해져요.
배운 점:
같은 클래스의 deleteManager()는 이미 이 패턴을 쓰고 있었어요.
막힐 땐 기존 코드에서 비슷한 로직을 어떻게 처리했는지 찾아보는 게 꿀힌트더라고요.
그리고 예외 메시지를 테스트가 assertEquals로 문자열 검증하고 있어서 한 글자만 달라도 깨져요.
메시지는 테스트에 적힌 걸 그대로 복사하는 게 안전합니다.
느낀 점
이번 과제는 새 기능을 만드는 과제가 아니라 남이 망가뜨린 코드를 진단하고 고치는 과제라서 저한텐 매우 색달랐어요..!!
에러 로그를 끝까지 읽는 습관, 테스트와 실제 코드 중 뭐가 스펙인지 판단하기, gitignore나 쿼리 메서드 네이밍 같은 도구의 암묵적 규칙들.. 이런 게 실무형 디버깅의 핵심이구나 싶었습니다...
특히 401이 세 번 떴는데 원인이 세 번 다 달랐던 게 제일 기억에 남아요.
증상이 같다고 원인이 같은 게 아니다!! 라는걸 한번더 명심하게 되는 과제였던것 같습니다 :)
개인적으로 틀린거 찾는것보다 새로운 코드 구현하는게 더 나은거가타효..ㅠㅠㅠ어려워..!!!ㅠㅠㅠㅠㅠㅠㅠㅠㅠ
'개인과제' 카테고리의 다른 글
| 개인과제 : AWS 배포 과제 (VPC / EC2 / RDS / Parameter Store / S3) (0) | 2026.08.10 |
|---|---|
| 개인과제 : CommercePJT (6인 팀프로젝트) (0) | 2026.07.23 |
| 개인과제 : CH 3 입문 Spring Boot 일정 관리 API 서버 구축 및 핵심 로직 분석 (0) | 2026.07.01 |
| 개인과제 : [git & IDEA] CH 2 계산기 과제 PART1. (0) | 2026.06.12 |
| 개인과제 : [git & github] 온보딩 주차 - 협업을 위한 git 활용 PART.2 (0) | 2026.06.12 |