[Java Spring] COGO 개발일지 - 코드 중복 해결에 대한 고민 해결책 (DI, AOP, 어노테이션)
코드 중복 해결에 대한 고민과 최선의 해결책



코드를 보니 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를 만들 수 있어 전체적인 코드 품질이 향상된다.