카테고리 없음
TIL Lv.5 ‘내’가 정의한 문제와 해결 과정
creator7087
2025. 4. 20. 10:45
1. 수상한 점: 제멋대로인 패키지 구조
패키지 구조가 기능과 무관하게 뒤섞여 있었고, 명확한 기준 없이 흩어진 클래스들이 존재. 예상 문제는 다음과 같음:
- 유지보수가 어려움
- 기능 추가 및 분석 시 클래스 위치 파악이 비효율적임
- 공통 로직 재사용성이 떨어짐
1-2. 문제 해결 과정: 단계별 리팩토링
1단계: 공통 로직 분리
- domain 하위에 섞여 있던 공통 클래스들을 common 패키지로 통합
2단계: 예외 처리 구조 통합
- 여러 도메인에 흩어진 예외 클래스들을 common.exception.custom으로 이동하여 관리 일관성 확보
3단계: 설정 및 기타 클래스 정리
패키지들을 아래와 같이 역할 중심으로 재구성
패키지 명내용 설명
aop | AOP 관련 Aspect 클래스 |
auth | @Auth 애너테이션, 관련 DTO 및 Resolver |
interceptor | 인증/인가 요청 흐름을 제어하는 인터셉터 클래스들 |
config | Spring 설정 관련 Bean, Config 파일 |
entity | BaseEntity 등 공통 상속용 엔티티 클래스 |
enums | 프로젝트 전반에서 사용하는 Enum 모음 |
exception | 전역/커스텀 예외 정의 및 핸들러 |
jwt | JWT 관련 Util, Filter 구성 |
1-3. 결론: 역할 기반 패키지 구조로 리팩토링 완료
리팩토링 결과, 각 기능별로 명확하게 책임이 분리되어 다음과 같은 장점을 확보할 수 있었습니다:
- 새로운 기능 추가 시 관련 위치를 쉽게 파악할 수 있음
- 공통 예외 처리 및 인증 로직을 손쉽게 적용 가능
- 도메인 간 의존성을 최소화하여 테스트 작성 및 협업이 용이해짐
2. 수상한 점: PersistenceConfig의 모호한 책임
- JPA의 감사 기능(Auditing) 설정을 위해 별도의 PersistenceConfig 클래스가 존재했으나, 설정이 중복되거나 불필요하게 분산되어 관리 책임이 모호함
2-2. 문제 해결 과정: 리팩토링
- PersistenceConfig 클래스 삭제
- ExpertApplication 클래스에 @EnableJpaAuditing 직접 추가
더보기
.해당 코드
@SpringBootApplication
@EnableJpaAuditing
public class ExpertApplication {
public static void main(String[] args) {
SpringApplication.run(ExpertApplication.class, args);
}
}
2-3. 결론: 설정 구조 측면
- JPA 감사 기능의 책임이 BaseEntity와 ExpertApplication으로 명확하게 구분
- BaseEntity: @CreatedDate, @LastModifiedDate 관리
- ExpertApplication: @EnableJpaAuditing으로 기능 활성화
- 불필요한 설정 클래스를 제거하여 구조 간결화 달성
3 . 수정한 점 : JwtFilter 등록 순서 설정 (setOrder) 적용
기존의 FilterConfig에서는 JwtFilter를 등록만 하고 필터 실행 순서를 지정하지 않아 다음과 같은 문제가 발생할 수 있었습니다:
- Spring Security 필터보다 먼저 또는 늦게 실행되어 인증 흐름이 꼬이거나,
요청이 제대로 처리되지 않는 현상 발생 가능 - 다른 필터들과의 우선순위가 명확하지 않아 디버깅 시 처리 순서 추적이 어려움
- 필터 충돌 또는 중복 실행 문제가 생길 여지가 있음
3-2. 문제 해결 방법
FilterRegistrationBean에 setOrder(int order)를 명시적으로 추가하여 필터의 실행 순서를 제어함.
더보기
@Bean
public FilterRegistrationBean<JwtFilter> jwtFilter() {
FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new JwtFilter(jwtUtil));
registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다.
registrationBean.setOrder(1);
return registrationBean;
}
3-3. 결론: JwtFilter의 실행 순서를 명시
JwtFilter의 실행 순서를 명시함으로써 필터 간 충돌 가능성을 줄이고, 인증 로직의 안정성을 높일 수 있게 되었습니다.
- 인증 필터의 의도된 흐름 보장
- 필터 순서 기반의 명확한 요청 처리 구조 확보
- 다양한 필터 간 우선순위 설정 관리가 쉬워짐
4. 수상한 점: @Auth AuthUser를 사용한 인증 방식 미사용
기존 컨트롤러 메서드는 다음과 같은 방식으로 JWT 인증 정보를 처리하고 있었습니다.
더보기
@DeleteMapping("/todos/{todoId}/managers/{managerId}")
public void deleteManager(
@RequestHeader("Authorization") String bearerToken,
@PathVariable long todoId,
@PathVariable long managerId
) {
Claims claims = jwtUtil.extractClaims(bearerToken.substring(7));
long userId = Long.parseLong(claims.getSubject());
managerService.deleteManager(userId, todoId, managerId);
}
주요 문제점:
- JWT 파싱 로직이 컨트롤러마다 반복됨 → 코드 중복 증가
- 인증 책임이 컨트롤러에 직접 드러나서 역할이 모호
- 인증 처리 방식이 노출되며 테스트와 유지보수 어려움
- 향후 인증 로직이 바뀔 경우, 모든 컨트롤러를 일일이 수정해야 함
4-2. 문제 해결 방법
@Auth 애너테이션과 AuthUser 객체를 활용해 인증 정보를 처리하는 방식으로 개선
더보기
@DeleteMapping("/todos/{todoId}/managers/{managerId}")
public void deleteManager(
@Auth AuthUser authUser,
@PathVariable long todoId,
@PathVariable long managerId
) {
long userId = authUser.getId();
managerService.deleteManager(userId, todoId, managerId);
}
- 인증 관련 로직은 Resolver에서 캡슐화
- authUser는 이미 인증된 사용자 정보를 담은 객체로 전달되므로 컨트롤러가 인증에 신경 쓸 필요 없음
- 전체 컨트롤러 코드가 더 간결하고 직관적으로 변화
더보기
⚠️ 주의: @Auth는 반드시 AuthUser 타입과 함께 사용해야 함
@Auth 애너테이션은 인증된 사용자 정보를 파라미터에 주입하기 위한 목적으로 정의된 것으로,
파라미터가 AuthUser 타입이 아니면 다음과 같은 예외 발생:
new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.")
즉, @Auth는 AuthUser authUser 와 함께 쌍으로 사용되어야만 정상 동작합니다.
4-3. 결론: 인증 책임 분리 및 컨트롤러 코드 단순화
- @Auth와 AuthUser의 조합을 통해 인증 관련 책임을 전담 Resolver로 위임
- 반복되던 JWT 파싱 로직 제거로 가독성 향상, 유지보수 용이성 증가
- 보안 로직과 비즈니스 로직이 분리되어 아키텍처적으로도 더 깔끔한 구조 확보
5. 수상한 점: HTTP 상태 코드 미반영
- ⚠ 문제 요약:
- 반환 타입이 void이며, 클라이언트에게 처리 결과에 대한 상태 코드 응답이 없음
- RESTful API 명세 측면에서 성공 여부를 명확히 알 수 없음
5-2. 문제 해결 방법
해결 전략:
- 서비스 계층에서 예외를 throw → @ControllerAdvice를 통해 전역적으로 처리를 함으로 예외 발생은 작성 X
- 컨트롤러는 처리 성공 시 200 OK, 204 no_content 등의 HTTP 상태 코드만 반환
- 명시적이면서도 간결한 ResponseEntity 사용으로 REST API 응답 명확화
5-3. 결론: 계층 간 책임 분리 + 간결한 API 응답
- 컨트롤러는 단순한 역할만 수행 → 응답 상태 코드 반환 (200 OK)
- 예외 처리 책임은 서비스 → 전역 예외 처리기로 위임 → 계층 간 역할 명확화
- 클라이언트는 일관된 응답 포맷과 상태 코드로 처리 결과를 쉽게 해석 가능
6. 수상한 점: 관리자 기능 전용 도메인(admin) 분리 및 AOP 적용 구조
기존 문제 상황:
- 아래와 같은 메서드들이 comment, user 도메인의 컨트롤러에 포함되어 있었음
더보기
기존 코드
@DeleteMapping("/admin/comments/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable long commentId) { ... }
@PatchMapping("/admin/users/{userId}")
public ResponseEntity<Void> changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) { ... }
⚠ 주요 문제점:
- 관리자(admin) 기능임에도 일반 도메인(controller/user, controller/comment 등)에 섞여 있어 역할 구분이 불분명
- 이미 서비스 계층은 UserAdminService, CommentAdminService 등으로 분리되어 관리되고 있었음
- AOP로 관리자 기능만 별도 관리하고 싶었으나, 패키지 기준 포인트컷 적용이 어려움
6-2. 문제 해결 방법
리팩토링 전략:
- /admin/... 경로의 API를 담당하는 컨트롤러들을 domain.admin.controller 패키지로 이동
- 서비스도 domain.admin.service로 함께 정리하여 admin 도메인 별도 구성
- AOP 포인트컷을 다음과 같이 설정하여 admin 도메인 컨트롤러만 감지
더보기
@Around("execution(* org.example.expert.domain.admin.controller..*(..))")
public Object logAdminAccess(ProceedingJoinPoint joinPoint) throws Throwable {
// 관리자 접근 로그, 감사 처리 등
}
구조 예시:
더보기
└── domain
├── user
│ └── UserController.java
├── comment
│ └── CommentController.java
└── admin
├── controller
│ ├── UserAdminController.java
│ └── CommentAdminController.java
└── service
├── UserAdminService.java
└── CommentAdminService.java
6-3. 결론: 도메인 역할 분리 + 관리자 기능 일관 관리
- 사용자용 기능과 관리자용 기능을 패키지 구조상 명확히 분리
- admin 도메인은 특수 권한을 요구하는 기능만 포함되므로 보안, 로깅, 접근 제어를 AOP로 집중 처리 가능
- 서비스 계층 또한 일관되게 분리되어 관리 편의성 및 테스트 용이성 향상
7 수상한 점: 사용되지 DTO 클래스 존재와 댓글 수정/삭제 기능 미비
- UserSaveResponse는 과거 로그인/회원가입 등의 응답 용도로 사용되었을 가능성이 있으나,
현재 로직에서 사용되지 않음 - 더 이상 참조되지 않는 DTO는 유지보수 시 혼란 유발
- 추후 리팩토링 대상인지 아닌지 판단이 어렵기 때문에 과감히 삭제 결정
- 댓글 관련 컨트롤러에는 조회/등록 기능만 있고,
작성자가 자신의 댓글을 수정하거나 삭제할 수 있는 API가 존재하지 않았음 - 사용자 입장에서는 작성한 댓글을 관리할 수 없다는 기능적 제약이 존재
7-2. 문제 해결 방법
1. 불필요 클래스 제거
- UserSaveResponse DTO 완전 삭제
- 관련 코드 전부 제거하여 클린 코드 유지
2. 사용자 본인만 수정/삭제 가능한 댓글 기능 추가
컨트롤러에서 @Auth AuthUser를 통해 로그인된 사용자 ID를 받아와,
서비스 내부에서 댓글 작성자 ID와 비교하여 본인 확인 후 수정/삭제 가능하도록 구현
7-3 결론: 사용자 권한 기반의 안전한 수정/삭제 기능 제공
- 사용자 본인이 작성한 댓글에 대해서만 수정/삭제가 가능하도록 제한하여 보안성 강화
- API 응답도 HTTP 상태 코드 기반으로 설계 (200 OK, 204 No Content, 403 Forbidden, 404 Not Found)
- 전역 예외 처리기를 통해 권한 없음 등의 예외를 일관되게 처리 가능
8수상한 점: MethodArgumentNotValidException의 부재 -> 테스트 코드 중 확인
- @Valid를 사용하여 입력값 검증을 시도했지만,
유효성 실패 시 발생하는 MethodArgumentNotValidException에 대한 예외 처리 로직이 없음 - 그 결과, 요청 본문이 잘못되어도 클라이언트는 정확한 에러 메시지를 받지 못함
- 디버깅 어려움, 클라이언트 입장에서 무엇이 잘못되었는지 알 수 없는 응답(500 등) 수신
8-2. 문제 해결 방법
✅ GlobalExceptionHandler에 예외 처리 메서드 추가
더보기
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put("message", error.getDefaultMessage()) // message 키 사용
);
return ResponseEntity.badRequest().body(errors);
}
✅ 테스트 코드 확인 가능
더보기
@Test
void 댓글_저장_실패_null_case() throws Exception {
// given
String token = jwtUtil.createToken(1L, "test@example.com", UserRole.USER);
CommentSaveRequest request = new CommentSaveRequest(null);
// when & then
mockMvc.perform(post("/todos/1/comments")
.header("Authorization", token)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request)))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.message").value("댓글은 빈칸이면 안됩니다"));
}
}
8-3. 결론: API의 유효성 검증 안정성 확보
- @Valid 사용 시 발생 가능한 예외에 대한 중앙 집중형 처리 도입
- 클라이언트가 어떤 필드가 잘못됐는지 알 수 있어 UX 및 개발 협업 효율성 향상
- 모든 유효성 검사는 서비스 로직 진입 전 차단되므로 불필요한 리소스 사용 감소