코드 중복 해결에 대한 고민과 최선의 해결책



코드를 보니 getUserNameByAuthentication 메소드가 여러 컨트롤러에 걸쳐 중복되고 있다. 이런 중복은 DRY(Don't Repeat Yourself) 원칙을 위반하는 전형적인 케이스다. 이런 상황에서 몇 가지 해결책을 생각해볼 수 있다.
현재 상황 분석
현재 각 컨트롤러에서 동일한 코드로 인증된 사용자의 이름을 가져오는 메소드를 반복 구현하고 있다. 이렇게 중복된 코드는:
- 유지보수 어려움 - 한 곳에서 수정이 필요할 때 모든 곳을 찾아 수정해야 한다
- 버그 발생 가능성 증가 - 일부만 수정하고 나머지는 놓칠 수 있다
- 코드 품질 저하 - 불필요한 코딩으로 전체 코드베이스 비대화된다
해결책들
1. 유틸리티 클래스 만들기
public class AuthenticationUtils {
public static String getUserNameByAuthentication(Authentication authentication) throws Exception {
CustomOAuth2User principal = (CustomOAuth2User) authentication.getPrincipal();
if (principal == null) throw new Exception(); // TODO: Exception 만들기
return principal.getUsername();
}
}
이 방법은 간단하고 직관적이다. 하지만 정적 메소드를 사용한다는 특징이 있다.
2. 추상 베이스 컨트롤러 사용하기
public abstract class BaseController {
protected String getUserNameByAuthentication(Authentication authentication) throws Exception {
CustomOAuth2User principal = (CustomOAuth2User) authentication.getPrincipal();
if (principal == null) throw new Exception(); // TODO: Exception 만들기
return principal.getUsername();
}
}
@RestController
@RequestMapping(MENTOR_URI)
@RequiredArgsConstructor
@Tag(name = "MENTOR", description = "멘토 관련 api")
public class MentorController extends BaseController {
// 기존 코드...
}
상속을 통해 코드 재사용성을 높이는 방법이다. 다만 자바는 단일 상속만 지원하기 때문에 다른 베이스 클래스가 필요할 경우 제한이 생길 수 있다.
3. AuthenticationHelper 빈 만들기
@Component
public class AuthenticationHelper {
public String getUserNameByAuthentication(Authentication authentication) throws Exception {
CustomOAuth2User principal = (CustomOAuth2User) authentication.getPrincipal();
if (principal == null) throw new CustomAuthenticationException("사용자 인증 정보가 없습니다");
return principal.getUsername();
}
}
@RestController
@RequestMapping(USER_URI)
@RequiredArgsConstructor
@Tag(name = "USER", description = "유저 관련 api")
public class UserController {
private final UserService userService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
// 사용예시
public void someMethod(Authentication auth) throws Exception {
String username = authHelper.getUserNameByAuthentication(auth);
// ...
}
}
이 방법은 스프링의 DI(의존성 주입)를 활용하는 방식이다.
4. 커스텀 어노테이션과 AOP 사용
복잡하지만 더 우아한 해결책으로 AOP를 활용할 수도 있다. 예를 들어 메소드에 @CurrentUsername 같은 어노테이션을 만들어서 사용하는 방식이다.
최선의 해결책
내 생각엔 3번 AuthenticationHelper 빈이 가장 좋은 방법 같다. 이유는:
- 유연성: 필요에 따라 다른 메소드도 추가 가능하다
- 스프링 철학 일치: 컴포넌트 기반 DI 패턴에 적합하다
- 명시적 의존성: 필요한 곳에서만 주입받아 사용한다
- Exception 처리: 커스텀 예외 처리도 깔끔하게 구현 가능하다
결론적으로, 이런 공통 코드는 별도 컴포넌트로 분리해서 필요한 곳에 주입받아 사용하는 것이 장기적으로 가장 유지보수하기 좋고 확장성 있는 방법이라고 생각한다.
현재 패턴의 문제점
// UserController
@PostMapping()
@Operation(summary = "기본정보 기입")
@ApiResponse(responseCode = "201", description = "성공!")
public ResponseEntity<ApiResponseGenerator<UserInfoResponse>> joinWithMentor(
Authentication authentication,
@RequestBody UserJoinRequest dto
) throws Exception {
UserInfoResponse response = userService.saveUserInformation(
getUserNameByAuthentication(authentication), dto);
return ResponseEntity.created(URI.create(USER_URI))
.body(ApiResponseGenerator.onSuccessCREATED(response));
}
// UserService
@Transactional
public UserInfoResponse saveUserInformation(String username, UserJoinRequest dto) {
User user = findUserByUsername(username);
user.setName(dto.getName());
user.setPhoneNum(dto.getPhoneNum());
return UserConverter.toResponse(userRepository.save(user));
}
현재 코드는 다음과 같은 패턴을 보인다:
- 컨트롤러에서 Authentication으로부터 username을 추출한다
- 그 username을 서비스 계층에 넘긴다
- 서비스 계층에서 다시 username으로 유저를 조회한다
이 방식은 다음과 같은 단점이 있다:
- 반복적인 코드 패턴이 모든 엔드포인트에서 발생한다
- 서비스 메소드마다 username 파라미터와 유저 조회 로직이 중복된다
어노테이션 기반 해결책
어노테이션 기반으로 접근하면 다음과 같은 장점이 있다:
- 깔끔한 컨트롤러 코드: 인증 로직이 숨겨지고 더 명확한 의도를 표현할 수 있다
- 반복 로직 제거: username으로 유저를 조회하는 중복 코드가 사라진다
- 관심사의 분리: 인증과 사용자 조회 로직을 특정 클래스에 집중시킬 수 있다
- 일관성: 모든 엔드포인트에서 동일한 방식으로 유저 정보를 획득한다
다음과 같이 구현할 수 있다:
// 커스텀 어노테이션 생성
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}
// 어노테이션을 처리할 HandlerMethodArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
private final UserRepository userRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(User.class) &&
parameter.hasParameterAnnotation(CurrentUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication =
(Authentication) webRequest.getUserPrincipal();
if (authentication == null) {
throw new CustomAuthenticationException("인증 정보가 없습니다");
}
CustomOAuth2User principal = (CustomOAuth2User) authentication.getPrincipal();
if (principal == null) {
throw new CustomAuthenticationException("사용자 정보가 없습니다");
}
return userRepository.findByUsername(principal.getUsername())
.orElseThrow(() -> new CustomAuthenticationException("사용자를 찾을 수 없습니다"));
}
}
// WebMvcConfigurer에 Resolver 등록
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver currentUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
이제 컨트롤러와 서비스는 다음과 같이 변경된다:
// UserController (수정 후)
@PostMapping()
@Operation(summary = "기본정보 기입")
@ApiResponse(responseCode = "201", description = "성공!")
public ResponseEntity<ApiResponseGenerator<UserInfoResponse>> joinWithMentor(
@CurrentUser User user, // 어노테이션으로 바로 User 객체 주입
@RequestBody UserJoinRequest dto
) {
UserInfoResponse response = userService.saveUserInformation(user, dto);
return ResponseEntity.created(URI.create(USER_URI))
.body(ApiResponseGenerator.onSuccessCREATED(response));
}
// UserService (수정 후)
@Transactional
public UserInfoResponse saveUserInformation(User user, UserJoinRequest dto) {
user.setName(dto.getName());
user.setPhoneNum(dto.getPhoneNum());
return UserConverter.toResponse(userRepository.save(user));
}
장점 요약
- 코드 간소화: 컨트롤러와 서비스 모두 더 간결해진다
- 명확한 의도: 파라미터 자체가 도메인 객체여서 의도가 명확하다
- 중앙화된 로직: 인증과 사용자 조회 로직이 한 곳에 집중된다
- 재사용성: 모든 컨트롤러에서 일관되게 사용할 수 있다
- 에러 처리: 인증 및 사용자 조회 과정의 에러를 한 곳에서 처리할 수 있다
이 방식은 스프링의 철학에도 부합하며, 다수의 대규모 스프링 프로젝트에서 사용하는 방식이다. 인증 로직을 숨기고 도메인 중심적인 API를 만들 수 있어 전체적인 코드 품질이 향상된다.
'백엔드 > Java + Spring' 카테고리의 다른 글
[Java Spring] COGO 개발일지 - Spring Security에서 JWT 인증 처리 방식 비교 (JwtAuthenticationFilter) (0) | 2025.04.01 |
---|---|
[백엔드] Blog - 모든 엔티티 생성 시간, 업데이트 시간 설정하기 @CreatedDate, @LastModifiedDate, BaseEntity (1) | 2024.09.24 |
[백엔드] Blog - 전역 예외 커스텀 처리하기 @RestControllerAdvice, @ExceptionHandler (1) | 2024.09.24 |
[백엔드] Blog - 실행 오류 정리 (application.yml, SDK, test) (0) | 2024.09.19 |
[백엔드] 회원탈퇴(Soft delete) 및 회원복구 (1) | 2024.09.08 |