백엔드/Java + Spring

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

커피와 개발자 2025. 3. 22. 21:17
728x90
반응형

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

 

코드를 보니 getUserNameByAuthentication 메소드가 여러 컨트롤러에 걸쳐 중복되고 있다. 이런 중복은 DRY(Don't Repeat Yourself) 원칙을 위반하는 전형적인 케이스다. 이런 상황에서 몇 가지 해결책을 생각해볼 수 있다.

현재 상황 분석

현재 각 컨트롤러에서 동일한 코드로 인증된 사용자의 이름을 가져오는 메소드를 반복 구현하고 있다. 이렇게 중복된 코드는:

  1. 유지보수 어려움 - 한 곳에서 수정이 필요할 때 모든 곳을 찾아 수정해야 한다
  2. 버그 발생 가능성 증가 - 일부만 수정하고 나머지는 놓칠 수 있다
  3. 코드 품질 저하 - 불필요한 코딩으로 전체 코드베이스 비대화된다

해결책들

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 빈이 가장 좋은 방법 같다. 이유는:

  1. 유연성: 필요에 따라 다른 메소드도 추가 가능하다
  2. 스프링 철학 일치: 컴포넌트 기반 DI 패턴에 적합하다
  3. 명시적 의존성: 필요한 곳에서만 주입받아 사용한다
  4. 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));
}

 

현재 코드는 다음과 같은 패턴을 보인다:

  1. 컨트롤러에서 Authentication으로부터 username을 추출한다
  2. 그 username을 서비스 계층에 넘긴다
  3. 서비스 계층에서 다시 username으로 유저를 조회한다

이 방식은 다음과 같은 단점이 있다:

  • 반복적인 코드 패턴이 모든 엔드포인트에서 발생한다
  • 서비스 메소드마다 username 파라미터와 유저 조회 로직이 중복된다

어노테이션 기반 해결책

어노테이션 기반으로 접근하면 다음과 같은 장점이 있다:

  1. 깔끔한 컨트롤러 코드: 인증 로직이 숨겨지고 더 명확한 의도를 표현할 수 있다
  2. 반복 로직 제거: username으로 유저를 조회하는 중복 코드가 사라진다
  3. 관심사의 분리: 인증과 사용자 조회 로직을 특정 클래스에 집중시킬 수 있다
  4. 일관성: 모든 엔드포인트에서 동일한 방식으로 유저 정보를 획득한다

다음과 같이 구현할 수 있다:

// 커스텀 어노테이션 생성
@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));
}

장점 요약

  1. 코드 간소화: 컨트롤러와 서비스 모두 더 간결해진다
  2. 명확한 의도: 파라미터 자체가 도메인 객체여서 의도가 명확하다
  3. 중앙화된 로직: 인증과 사용자 조회 로직이 한 곳에 집중된다
  4. 재사용성: 모든 컨트롤러에서 일관되게 사용할 수 있다
  5. 에러 처리: 인증 및 사용자 조회 과정의 에러를 한 곳에서 처리할 수 있다

이 방식은 스프링의 철학에도 부합하며, 다수의 대규모 스프링 프로젝트에서 사용하는 방식이다. 인증 로직을 숨기고 도메인 중심적인 API를 만들 수 있어 전체적인 코드 품질이 향상된다.

728x90
반응형