본문 바로가기

Project

[기능 개선] - Refactoring, Test, AOP

프로그램을 개발하고, 기능이 문제없이 구현되었을 때 개발자들이 다음으로 고려할 수 있는것은 어떻게 하면 성능을 향상시킬 수 있을까? 입니다. 이를 행하기 위해선 다양한 방법이 존재합니다. 이번 글에선 Refactoring, Test, AOP 에 대해서 알아보도록 하겠습니다.


1. Refactoring

리팩토링은 소프트웨어의 외부 동작을 변경하지 않으면서 내부 구조를 개선하는 프로세스 입니다. 주로 코드의 가독성, 유지보수성, 확장성을 높이기 위해 사용됩니다.

실제로 적용할 수 있는 방법은 Early Return, 불필요한 If-else 문 및 주석 최소화, 코드 포맷팅, 일관된 네이밍 컨벤션 적용등이 있습니다.

 

방법

 

  • 메서드 추출: 중복된 코드 블록을 메소드로 추출하여 코드의 재사용성을 높이고 가독성을 개선합니다.
  • 클래스 추출: 하나의 클래스가 너무 많은 책임을 가지고 있다면, 이를 여러 개의 클래스로 나누어 책임을 분리합니다.
  • 변수명 변경: 의미 없는 변수명을 의미 있는 이름으로 변경하여 코드의 가독성을 높입니다.

 

다음은 실제 예시를 보도록 하겠습니다.

 

1.1 Early Return

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {

        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );
        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

        return new SignupResponse(bearerToken);
    }

 

이러한 메서드가 있다고 가정하면, 시작 시 `encodedPassword` 가 실행되며 비밀번호가 한번 인코딩 되고, UserRole 객체에 정보가 담긴 뒤, Email 을 확인하는 검증을 거치게 됩니다.

 

사실 Email 이 이미 존재하는 경우에는 메서드 자체가 InvalidRequestException 이 진행되면서 제대로 끝나지 않아야 하는데, 앞에서 시행되는 두 과정은 불필요하게 진행됩니다. 따라서 다음과 같이 코드를 수정할 수 있겠습니다.

 

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {
    
        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        } //이메일 검증을 먼저 시행

        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );
        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

        return new SignupResponse(bearerToken);
    }

 

 

이렇게 하면 불필요하게 시행되는 메서드나 인스턴스화되는 객체가 없이 `Early Return`을 통해 좀 더 프로그램의 성능이 개선될 수 있습니다.

 

1.2 if-else 최소화

    public String getTodayWeather() {
        ResponseEntity<WeatherDto[]> responseEntity =
                restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);

        WeatherDto[] weatherArray = responseEntity.getBody();
        if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
            throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
        } else {
            if (weatherArray == null || weatherArray.length == 0) {
                throw new ServerException("날씨 데이터가 없습니다.");
            }
        }
        //날씨 데이터 조회하는 로직...
        throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
    }

 

이러한 로직이 있다고 가정해 봅니다. 날씨 데이터를 가져오는 조건문에 else 조건문이 추가적으로 포함되어 있는데, 사실 날씨 데이터가 존재하지 않으면 날씨데이터를 가져오는데 실패하는 것은 정해진 바이기 때문에, 먼저 검증해 주는게 좋습니다. 이렇게 중첩되어 조건을 반복하게 되면 Early Return 의 관점에서 효율이 좋지 않고 코드의 가독성도 떨어지게 됩니다.

위의 코드는 다음과 같이 수정할 수 있습니다.

    public String getTodayWeather() {
        ResponseEntity<WeatherDto[]> responseEntity =
                restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);

        WeatherDto[] weatherArray = responseEntity.getBody();
        if (weatherArray == null || weatherArray.length == 0) {
            throw new ServerException("날씨 데이터가 없습니다.");
        }
        if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
            throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
        }
        //날씨를 찾아로는 로직...
        throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
    }

 

1.3 불필요한 주석 제거

주석은 좋은 설명이 될 수도 있지만, 코드의 내용이 중복되거나 코드의 추상화 수준과 주석의 추상화 수준이 동일한 경우에는 도움이 되지않다고 할 수 있습니다.

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

 

위와 같은 코드는 주석의 내용과 코드의 내용이 거의 완벽하게 일치하기 때문에 주석을 사용한 개별적인 설명이 필요없는 상황이라 제거하는 것이 효율적입니다.

 

1.4 그 외

코드 클린업(사용되지 않는 메서드 제거), 네이밍 컨벤션 적용, 캐싱, 비동기 처리 등으로 성능을 개선 시킬 수 있습니다.

 

2. Test

테스트 코드는 소프트웨어의 특정 기능이 예상대로 작동하는지를 확인하기 위해 작성되는 코드입니다. 주로 `단위 테스트(Unit Testing)`와 `통합 테스트(Integration Testing)`가 있습니다.

 

방법

  • JUnit과 Mockito 사용: JUnit은 단위 테스트 프레임워크, Mockito는 목(mock) 객체를 생성하는 라이브러리입니다.
  • 테스트 케이스 작성: 각 기능에 대한 테스트 케이스를 작성하여 기능이 정상적으로 작동하는지 확인합니다.
@ExtendWith(MockitoExtension.class)
class ManagerServiceTest {

    @Mock
    private ManagerRepository managerRepository;
    @Mock
    private UserRepository userRepository;
    @Mock
    private TodoRepository todoRepository;
    @InjectMocks
    private ManagerService managerService;

    @Test
    public void manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() {
        // given
        long todoId = 1L;
        given(todoRepository.findById(todoId)).willReturn(Optional.empty());

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> managerService.getManagers(todoId));
        assertEquals("Manager not found", exception.getMessage());
    }

 

위와 같이 특정한 시나리오를 생각하며, 기능에 대한 테스트를 진행합니다. 이러한 테스트를 통해, 프로그램이 배포후에 유지보수를 진행할 때 매우 효율적으로 처리할 수 있게됩니다.

 

3. AOP (Aspect-Oriented Programming) 

AOP는 특정 기능을 여러 클래스에서 공통적으로 사용하는 데 도움을 주는 프로그래밍 패러다임입니다. 주로 로깅, 보안, 트랜잭션 관리 등을 위한 코드 중복을 줄이는 데 사용됩니다.

AOP 는 API 의 기능이 메인 기능이라고 생각했을 때, 부가적인 기능으로써 API 가 실행되는 여러 시점과 위치를 지정하여 사용할 수 있습니다.

방법

  • Aspect: AOP의 핵심 개념으로, 여러 위치에서 사용할 수 있는 공통의 기능을 정의합니다.
  • Pointcut: 어떤 메소드에 Aspect를 적용할 것인지를 정의합니다.
  • Advice: 실제로 적용할 기능(로깅, 트랜잭션 등)을 정의합니다.

 

@Aspect
@Component
@RequiredArgsConstructor
public class AccessLogAop {
    private final AccessLogRepository accessLogRepository;
    private final HttpServletRequest httpServletRequest;

    @Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.*(..))")
    private void comment() {}

    @Pointcut("execution(* org.example..expert.domain.user.controller.UserAdminController.*(..))")
    private void user() {}

    @Around("comment() || user()")
    public Object logAccess(ProceedingJoinPoint joinPoint) throws Throwable {
        // 요청 시각
        LocalDateTime requestTime = LocalDateTime.now();
        // 요청 정보들
        long userId = getAuthenticatedUserId();
        String requestBody = getRequestBody();
        String requestUrl = httpServletRequest.getRequestURI();
        Object response = null;
        try{
            response = joinPoint.proceed(); //위에 해당하는 컨트롤러의 API 가 수행될때
            return response;
        } finally {
            String responseToJson = convertResponseToJson(response);
            AccessLog accessLog = new AccessLog(userId, requestTime, requestBody, requestUrl, responseToJson);
            accessLogRepository.save(accessLog);
        }
    }

    //필터에서 userId 불러와서 로그에 저장
    private long getAuthenticatedUserId() {
        Object userId = httpServletRequest.getAttribute("userId");
        return userId !=null ? Long.parseLong(userId.toString()) : 0;
    }

    //필터에서 Body 를 가져와서 저장
    private String getRequestBody() throws IOException {
        StringBuilder requestBody = new StringBuilder();
        BufferedReader reader = httpServletRequest.getReader();
        String line;
        while( (line = reader.readLine()) != null ) {
            requestBody.append(line);
        }
        return requestBody.toString();
    }

    //Object 인 response 를 Json 으로 변경
    private String convertResponseToJson(Object response) {
        try{
            ObjectMapper objectMapper = new ObjectMapper();
            return objectMapper.writeValueAsString(response);
        } catch (JsonProcessingException e) {
            return "Json 으로 변경 실패";
        }
    }
}

 

위의 코드는 관리자 API 가 실행 되었을 때, 그 API의 `요청 시각`, 요청한 `사용자의 ID`, 요청한 내용을 담고 있는 `Request Body`, 요청된 `URL`, 응답된 `Response` 등을 기록하기 위한 AOP 입니다.

 

`@Pointcut` 어노테이션을 사용하여 어느 위치에 해당 AOP 를 적용할지 지정하고, `@Around` 어노테이션을 사용해 AOP 가 API 호출 전과 후에 실행되는 코드를 정의할 수 있습니다. 여러 정보들을 `AccessLog` 라는 `Repository` 에 따로 담아 저장하는 방식을 채택하였습니다. `@Around` 어노테이션을 사용하면 전과 후를 기록할 수 있기 때문에, 특정 API 를 실행하는데 걸리는 시간도 기록할 수 있습니다.

 

이러한 방식으로 API가 실행될 때 적용하고 싶은 부가적인 메서드를 AOP로 정의하여 사용하면, 공통기능을 원하는 여러 클래스에 한번에 적용하며 효율적으로 처리할 수 있습니다.

 

이것으로 Refactoring, Test, AOP 에 대한 설명을 마치도록 하겠습니다. 각 내용에 대한 더 자세한 내용은 추후에 다뤄볼 에정입니다.

 

감사합니다.

728x90
반응형