본문 바로가기
토이프로젝트 - 백오피스

23.07.20

by J1-H00N 2023. 7. 20.

현재 진행중인 프로젝트에 적용하기 위해 aop에 대해 자세히 알아보려고 한다.

AOP = Aspect Oriented Programming(관점 지향 프로그래밍)란 간단히 말해 공통적인 로직을 모듈화하여 관리하는 것이다. 인증, 트랜잭션, 로깅 등의 로직들을 AOP를 통해 관리하면 각 비즈니스 모듈에는 관심사에 대한 로직 위주만 남길 수 있다. 또한 추가 기능을 추가/수정할 때 비즈니스 모듈을 수정하지 않고 수행할 수 있다.

 

spring에서 Spring AOP를 제공하지만, AspectJ를 AOP의 프레임워크로 사용할 수 있다.

 

위 둘을 비교하기 전, 핵심개념을 짚고 넘어가자.

아래 내용은 spring 공식 문서를 번역한 블로그를 참조했다.

https://logical-code.tistory.com/118

 

Spring AOP와 AspectJ 비교하기

Thanks to @ㅅㅈㅎ 님 덕분에 3-5 첫번째 문장의 오타를 수정했습니다. 감사합니다! (더 간편합니다다. ⇢ 더 간편합니다.) @김성수 님 덕분에 3-2. Weaving의 오타를 수정했습니다. 감사합니다! (컴파일

logical-code.tistory.com

 

· 애스팩트(Aspect):횡단 관심사의 동작과 그 횡단 관심사를 적용하는 소스 코드상의 포인트를 모은 것이다.

  즉, 하나 이상의 어드바이스(동작)와 포인트컷(동작을 적용하는 조건)을 조합한 것이다.

  @Aspect 애너테이션이 달린 일반 클래스를 사용하여 aspect를 구현할 수 있다. 

 

· 조인포인트(Joinpoint): 어드바이스가 실행하는 동작을 끼워 넣을 수 있는 때를 말한다.

  조인 포인트는 개발자가 고려해서 만들어 넣을 수 없는 AOP(제품)의 사양이다. 스프링에서는 메서드가 호출될 때와

  메서드가 원래 호출한 곳으로 돌아갈 떄가 어드바이스를 끼워 넣을 수 있는 조인 포인트다.

 

· 어드바이스(Advice):조인 포인트에서 실행되는 코드를 말한다. 로그 출력, 트랜잭션 관리 등의 코드가 기술된다. 

 

· 포인트컷(Pointcut): 어드바이스가 실행되는 하나 이상의 조인 포인트를 선택하는 표현식이다.

  표현식이나 패턴을 사용하여 포인트컷을 정의할 수 있다. 조인 포인트와 매칭되는 다양한 종류의 표현식을 사용할 수 있    으며,

  스프링 프레임워크는 AspectJ pointcut 표현 언어를 사용한다.

  

· 대상 객체(Targe object): 어드바이스가 적용되는 객체를 말한다. 대상 객체는 항상 프록시된다.

  이는 대상 메서드가 재정의되는 런타임에 하위 클래스가 생성되고, 어드바이스들은 해당 설정에 따라 포함됨을 의미한다.

 

· 위빙(Weaving): 애스팩트를 다른 애플리케이션 타입과 연결하는 프로세스다.

  런타임, 로드 타임 및 컴파일 타임에 위빙을 수행할 수 있다. 

 

이제 Spring AOP와 AspectJ를 비교해보자.

성능과 목적

우선, Spring AOP는 Spring IOC를 통한 간단한 AOP구현이 목적이며, 완전한 AOP 구현이 목적이 아니며 Beans에만 적용된다.

AspectJ는 완전한 AOP구현이 목적이며, 근원적인 기술이다. Spring AOP보다 복잡하지만, 강력하며 모든 객체에 적용 가능하다.

 

위빙 방식

Spring AOP는 런타임 위빙(Aspect가 대상 객체의 Proxy를 사용하는 애플리케이션 실행시에 위빙)을 사용하고, AspectJ는 컴파일 시점 위빙(Aspect 코드와 애플리케이션의 소스 모두 입력받아 위빙된 클래스 파일 생성), 컴파일 후 위빙(이미 존재하는 클래스 파일과 jar 파일을 위빙하기 위해 사용 := 바이너리 위빙), 로드 시점 위빙(바이너리 위빙과 비슷하나 위빙 시점이 클래스 파일이 JVM에 로드될 때까지 연기된다는 점이 다름) 을 사용할 수 있다.

 

내부 구조와 애플리케이션

Spring AOP는 Proxy 기반 프레임워크로, 대상 객체에 Aspect를 적용하기 위해 Proxy를 생성한다. 해당 객체가 인터페이스를 구현한다면 JDK 동적 Proxy 방식, 구현하지 않는다면 CGLIB Proxy 방식을 사용한다. Spring AOP는 JDK 동적 Proxy 방식을 선호한다.

반면 AspectJ는 Aspect가 클래스들과 함께 컴파일되기 때문에 런타임 시에는 아무것도 하지 않는다. 또한 어떠한 디자인 패턴도 요구하지 않는다.

 

Joinpoint

Spring AOP는 Proxy기반이므로 Java 클래스의 세분화와 Cross-Cutting Concerns(횡단 관심사항, 다수의 비즈니스 로직에서 반복적으로 발생하는 부분. ex) 인증, 트랜잭션 ...)의 적용이 필요하다. Spring Aspect는 static, final 메서드들이 오버라이드 될 수 없고 런타임 예외를 유발할 수 있기 때문에 해당 메서드들에는 적용할 수 없다. 즉, 메서드 실행에만 Joinpoint를 지원한다.

하지만 AspectJ는 실제 코드에 위빙하기 때문에 위와 달리 클래스를 세분화할 필요도 없으며 덕분에 많은 Joinpoint를 지원한다. 아래는 각각의 방식이 지원하는 Joinpoint를 정리한 표다.

동일 클래스 내 다른 메서드를 호출할 때 Spring AOP에서 제공하는 Proxy 메서드가 호출되지 않기 때문에 이것이 기능적으로 필요하다면 각기 다른 Bean으로 등록하거나 AspectJ를 사용해야 한다.

 

간편성

위와 같이 Spring AOP는 단점이 많음에도 불구하고, Spring AOP가 고려되는 이유는 간평성 때문이다. Spring AOP는 외부 컴파일러를 필요로 하지 않고, 런타임 위빙을 사용하기 때문에 일반적인 구현에 적합하다.

그러나 AspectJ는 AspectJ compiler 라는 컴파일러를 도입하고 모든 라이브러리들을 재 패키징해야한다.

 

성능

컴파일 시점 weaving은 런타임 시점 weaving 보다 훨신 빠르다. 실제로 AspectJ는 Spring AOP보다 8배에서 35배 빠르다고 한다.

 

요약

 

성능적으로는 AspectJ가 압도적이나 당장 구현하고자 하는 기능은 기본적이고, 간단하게 구현 가능하며 강의에서 배운 내용도 Spring AOP 뿐이니 당장은 이를 사용해 구현해보고자 한다.

 

아래 pointcut 예제를 보고 pointcut을 만들어보자.

구현하고자 하는 기능은 회원가입을 했을 때 입력한 비밀번호와 비밀번호를 수정했을 때 입력한 비밀번호를 최근 사용했던 비밀번호 목록에 추가해 다음에 같은 비밀번호로 수정하지 않게 만드는 것이다. 이를 위해서 해당 비밀번호를 비밀번호 사용 목록에 저장하는 기능을 구현할 것이다. 따라서 pointcut을 아래와 같이 만들었다.

package com.sparta.dtogram.user.service;

import com.sparta.dtogram.user.dto.SignupRequestDto;
import com.sparta.dtogram.user.entity.User;
import com.sparta.dtogram.user.entity.UserRoleEnum;
import com.sparta.dtogram.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;


    // ADMIN_TOKEN
    private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

    public void signup(SignupRequestDto requestDto) {
        String username = requestDto.getUsername();
        String nickname = requestDto.getNickname();
        String password = passwordEncoder.encode(requestDto.getPassword());

        // 회원 중복 확인
        Optional<User> checkUsername = userRepository.findByUsername(username);
        if (checkUsername.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        Optional<User> checkNickname = userRepository.findByNickname(nickname);
        if(checkNickname.isPresent()) {
            throw new IllegalArgumentException("중복된 nickname입니다.");
        }

        // email 중복확인
        String email = requestDto.getEmail();
        Optional<User> checkEmail = userRepository.findByEmail(email);
        if (checkEmail.isPresent()) {
            throw new IllegalArgumentException("중복된 Email 입니다.");
        }

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (requestDto.isAdmin()) {
            if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        // 사용자 등록
        User user = new User(requestDto, password, role);
        userRepository.save(user);
    }
}
package com.sparta.dtogram.user.controller;

import com.sparta.dtogram.common.dto.MsgResponseDto;
import com.sparta.dtogram.common.security.UserDetailsImpl;
import com.sparta.dtogram.user.dto.PasswordRequestDto;
import com.sparta.dtogram.user.dto.ProfileRequestDto;
import com.sparta.dtogram.user.dto.ProfileResponseDto;
import com.sparta.dtogram.user.service.ProfileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.RejectedExecutionException;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ProfileController {

    private final ProfileService profileService;

    @GetMapping("/profile/{id}")
    public ProfileResponseDto getProfile(@PathVariable Long id) {
        return profileService.getProfile(id);
    }

    @PutMapping("/profile")
    public ResponseEntity<MsgResponseDto> editProfile(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody ProfileRequestDto requestDto) {
        try {
            profileService.editProfile(userDetails.getUser(), requestDto);
            return ResponseEntity.ok().body(new MsgResponseDto("프로필 수정 성공", HttpStatus.OK.value()));
        } catch (RejectedExecutionException e) {
            return ResponseEntity.badRequest().body(new MsgResponseDto("작성자만 수정 할 수 있습니다.", HttpStatus.BAD_REQUEST.value()));
        }
    }

    @PutMapping("/profile/password")
    public ResponseEntity<MsgResponseDto> editPassword(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody PasswordRequestDto requestDto) {
        try {
            profileService.editPassword(userDetails.getUser(), requestDto);
            return ResponseEntity.ok().body(new MsgResponseDto("비밀번호 수정 성공", HttpStatus.OK.value()));
        } catch (RejectedExecutionException e) {
            return ResponseEntity.badRequest().body(new MsgResponseDto("작성자만 수정 할 수 있습니다.", HttpStatus.BAD_REQUEST.value()));
        }
    }
}
@Slf4j
@Aspect
@Component
public class PasswordHistoryAOP {

    private final PasswordHistoryRepository passwordHistoryRepository;

    public PasswordHistoryAOP(PasswordHistoryRepository passwordHistoryRepository) {
        this.passwordHistoryRepository = passwordHistoryRepository;
    }

    @Pointcut("execution(public * com.sparta.dtogram.user.service.UserService.signup(..))")
    private void signupPassword() {}

    @Pointcut("execution(public * com.sparta.dtogram.user.controller.ProfileController.editPassword(..))")
    private void editPassword() {}
}

 

AOP에 대해 공부하면 공부할수록 위 기능을 AOP로 구현하기 힘들다는 것만 알게 되었다.

회원가입은 메서드 내 객체가 필요하고, 비밀번호 수정은 전달값을 필요로 하기 때문에 각자 따로 구현해야 하는데, 그러면 AOP로 분리한 의미가 사라지고, 가능하다 하더라도 당장 구현하기에는 난이도가 너무 높아 그냥 각 메서드에서 따로 구현하는 것이 더 나을 듯 하다.

 

passwordHistory를 Queue로 구현해서 가장 오래된 정보를 poll로 빼려고 했으나 어째서인지 에러가 발생한다.

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.orm.jpa.JpaSystemException: Could not set value of type [org.hibernate.collection.spi.PersistentBag] : `com.sparta.dtogram.user.entity.User.passwordHistories` (setter)] with root cause

위와 같은 에러가 발생했는데, 찾아보니 repository를 초기화 하지 않거나, @Autowired를 쓰지 않아서 등 너무나도 많은 이유때문에 생기는 에러라서 제대로 된 답도 나오지 않았다.

아마 hibernate가 queue를 지원하지 않아서 생긴 오류같다.

 

 if (requestDto.getNewPassword1().equals(requestDto.getNewPassword2())) {
                boolean isUsedPassword = passwordHistoryRepository.existsByPassword(requestDto.getNewPassword2());
                if (!isUsedPassword) {
                    changed.setPassword(passwordEncoder.encode(requestDto.getNewPassword2()));
                    // 새로운 비밀번호 사용 비밀번호 목록에 저장
                    passwordHistoryRepository.save(new PasswordHistory(requestDto.getNewPassword2(), user));
                    if (changed.getPasswordHistories().size() > 3) {
                        PasswordHistory oldestPassword = passwordHistoryRepository.findAllByOrderByCreatedAtAsc().get(0);
                        passwordHistoryRepository.delete(oldestPassword);
                    }
                } else {
                    throw new IllegalArgumentException("최근 사용한 비밀번호입니다.");
                }

위 코드를 사용하여 기능을 구현하고 aop는 후에 다시 다뤄보기로 했다.

 

다음은 AWS S3를 사용해 이미지를 업로드 해보려고 한다.

 

'토이프로젝트 - 백오피스' 카테고리의 다른 글

23.07.24  (0) 2023.07.24
23.07.21  (0) 2023.07.21
23.07.19  (0) 2023.07.19
23.07.18  (0) 2023.07.18
23.07.17  (0) 2023.07.17