SpringBoot 배달의민족-27(완) AOP를 이용한 보안, 예외처리

hanteng·2022년 8월 11일
0

SpringBoot 배달의민족

목록 보기
27/27

우리는 이전에 스프링 시큐리티를 적용할때 일반 사용자는 관리자(사장님)페이지에 접근이
불가능하도록 설정했었습니다. 또한 관리자로 등록이 되어있는 사용자라면 관리자메뉴를
통해 자신이 운영중인 매장페이지에 들어가 여러가지 기능을 수행할수 있었습니다

하지만 현재 관리자로 등록된 이용자라면 주소창에 직접입력을 통해 자신의 매장이 아니더라도
접근이 가능합니다. 따라서 우리는 관리자라도 자신이 운영중인 매장이 아니라면 관리자페이지에
접근할수 없도록 막아줘야 합니다.

여러가지 방법이 있는데 가장 간단한 방법은 현재 로그인한 사용자가 접근하려고 하는
매장의 주인인지 아니지를 테이블에서 조회하는 방법입니다 하지만 이경우 매 접속시마다
테이블에 쿼리를 날려 조회하기 때문에 좋은 방법이 아닙니다

다른 방법으로는 테이블에서 현재 사용자가 운영중인 모든 매장번호를 조회하여 리스트로
세션에 저장하는 방법입니다 이 경우 맨 처음에만 테이블을 조회하므로 DB와의 반복적인
커넥션을 없앨수 있습니다. 하지만 보안이 필요한 모든 페이지에서 세션을 조회하는
코드가 중복되어 들어가게되므로 우리는 불필요한 코드중복을 없애고 필요한곳에서만
해당 기능을 사용할수 있도록 AOP를 이용하여 어노테이션화 시킬겁니다
AOP를 사용하기 위해 pom.xml에 라이브러리를 추가해줍니다

pom.xml 추가코드

		<!-- AOP -->
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-aop</artifactId>
		</dependency>

최상위패키지안에 aop라는 패키지를 새로 추가해주고 그안에 IsMyStore라는 이름의
어노테이션을 하나 만들어줍니다

IsMyStore.java 전체코드

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface IsMyStore {

}

이 어노테이션 클래스는 인터페이스이기 때문에 내부로직을 구현해줘야 합니다
aop패키지안에 AdminAOP 클래스를 하나 추가해줍니다

AdminAOP.java 전체코드

@Aspect
@Component
public class AdminAOP {
	
	@Autowired
	private AdminService adminService;

	
	@Around("@annotation(com.han.delivery.aop.IsMyStore)")
	public Object myStore(ProceedingJoinPoint j) throws Throwable   {
		long storeId = 0;
		Object[] args = j.getArgs();
		if(args.length > 0) {
			Object arg = args[0];
			
			if(arg instanceof Long) {
				storeId = (long) arg;
			} else if(arg instanceof StoreDto) {
				storeId = ((StoreDto) arg).getId();
			} else if(arg instanceof FoodDto) {
				storeId = ((FoodDto) arg).getStoreId();
			} 
		}
		if(!isMyStore(storeId)) { 
			System.out.println("aop 에러");
			return new ResponseEntity<Object>(HttpStatus.UNAUTHORIZED);
		}
		Object returnObj = j.proceed();
		return returnObj;
	}
	
	public boolean isMyStore(long storeId) throws IOException {
		ServletRequestAttributes attr = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
		HttpSession session = attr.getRequest().getSession();
		List<Long> storeIdList = (List<Long>) session.getAttribute("myStore");
		
		if(storeIdList == null) {
			SecurityContext sc = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
			CustomUserDetails user = (CustomUserDetails) sc.getAuthentication().getPrincipal();
			long userId = user.getId();
			storeIdList = adminService.getMyStoreId(userId);
	        	session.setAttribute("myStore", storeIdList);
		} 
		
		if(storeIdList.size() == 0) {
			return false;
		} else {
			if(storeIdList.contains(storeId)) {
				return true;
			} else {
				return false;
			}
		}
	}

}

AOP클래스로 사용하기 위해 @aspect를 붙여주고 해당 클래스를 스캔할수 있도록
@component를 붙여줍니다

@Around는 메소드 호출 이전, 이후, 예외발생 등 모든 시점에서 동작하도록 해주며
annotation에 이전에 만든 어노테이션의 위치를 적어 해당 어노테이션에서 메서드가
실행되도록 해줍니다

ProceedingJoinPoint는 해당 어노테이션이 붙어있는 타깃을 뜻하며 해당 타깃메서드의
여러 정보를 가져올수 있습니다

세션을 참조하여 세션이 비어있을경우 테이블에서 조회하여 운영중인 매장 리스트를
세션에 저장하고 이렇게 저장한 리스트에 접근하고자 하는 매장Id가 존재하면
타깃 메서드를 실행합니다

AdminService.java 추가코드

	//관리중인 매장 리스트
	public List<Long> getMyStoreId(long userId) {
		return adminMapper.getMyStoreId(userId);
	}

AdminMapper.java 추가코드

	//관리중인 매장 리스트
	public List<Long> getMyStoreId(long userId);

AdminMapper.xml 추가코드

	<select id="getMyStoreId" resultType="long">
		SELECT STORE_ID FROM DL_MY_STORE WHERE USER_ID = #{userId } 
	</select>

AOP는 프록시객체를 사용하기 때문에 스트링부트 어플리케이션 실행시 AOP프록시객체를
사용하겠다고 선언해줘야 합니다 (@EnableAspectJAutoProxy)

이제 기존 AdminController의 메서드들에 @IsMyStore어노테이션을 붙여줍니다

이제 자신이 주소창에 자신이 운영중인 매장이 아닌 다른 매장으로 접근할 경우
다음과 같은 화면이 나오게 됩니다

현재 우리는 예외처리를 따로 해두지 않았기 때문에 사용자한테 예외발생 내역이
그대로 보이게 됩니다 이러한 내역을 사용자에게 노출하는건 좋지 않기 때문에
우리는 AOP를 이용하여 한곳에서 모든 예외에 대한 처리를 할겁니다

그전에 예외에 대해서 설명하자면 우리가 기존에 리뷰작성시 이미지업로드를 하기 위해
작성한 클래스를 기억하실겁니다

이때 파일I/O에서 예외가 발생할수 있기 때문에 try catch문으로 감싸주고 예외발생시
false를 반환하여 컨트롤러에서 그에 대한 처리를 할수가 있었습니다

그렇다면 만약 @transactional 어노테이션이 붙은 메서드안에서 try catch문으로 감싼
부분에서 예외가 발생한다면 똑같이 false를 반환하고 컨트롤러에서 이를 처리하여
위와 똑같이 우리가 설정한 메시지를 출력할까요?

예외발생 메시지가 그대로 담겨져서 보이게 됩니다 그 이유는

Exception은 Unchecked와 Checked Exception으로 나뉘기 때문입니다

Checked Exception은 파일IO나 클래스를 찾지 못했을 경우 즉 컴파일 시점에서
확인할수 있는 예외로 우리가 제일 위에서 말했던 사용자가 업로드한 이미지를
서버에 저장하기 위해 transferTo를 사용할때 try catch문으로 감싸주지 않으면
컴파일시 에러가 발생하여 프로그램을 실행할수가 없습니다

Unchecked Exception은 런타임 시점에서 확인할수 있는 예외로 Null값이 들어가거나
객체의 형변환 연산을 잘못했을때 등을 나타냅니다

차이점은 Checked Exception은 롤백이 발생하지 않고 Unchecked Exception는
롤백이 발생한다는 점입니다. 파일I/O는 Unchecked Exception이므로 서버에 파일을
저장하지 못했어도 @Transactional 어노테이션을 붙여도 롤백이 발생하지 않아 return false를
타게되어 다시 Controller단으로 넘어가badrequest를 발생시키고
body에 우리가 원하는 텍스트를 담아서 응답해줄수 있습니다

하지만 만약 주문등의 기능에서 null값이 들어가게 된다면 Unchecked Exception이
발생하며 try문을 사용하더라도 롤백이 발생하여 return false를 타지 않고 말 그대로
뒤로감기를 하게 됩니다. 즉 예외를 발견하는 순간 더 이상 남은 로직은 시행하지 않습니다
그러므로 다시 return하여 Controller단으로 가서 로직을 수행하지 않고
RuntimeException이 예외를 처리하여 ajax요청에 대한 실패응답으로 body에
예외발생메시지를 그대로 담아 보내게 됩니다

물론 Transaction을 사용하지 않는다면 롤백도 발생하지 않으므로 우리가 원하는
결과를 얻을수있지만 Transaction을 사용하지 않아 발생하는 문제는 예외처리를
하지 않아 발생하는 문제보다 더 크기 때문에 좋은 방법이 아닙니다 물론 Controller단에서
try catch문을 사용하는 방법도 있지만 중복된 코드가 반복된다는점과 Service단에
있는 메서드들이 맞물려있거나 하나의 메서드에서 여러번의 쿼리가 나가는등과 같은
상황에서는 Service단의 메서드 전체를 try로 감싸는건 좋지 않은 방법입니다

따라서 우리는 AOP를 이용하여 모든 예외를 한곳으로 모아서 처리하여 반복되는
코드를 줄이고 특정 시점에서 예외를 처리할수 있도록 해야합니다

aop패키지안에 exception패키지를 추가하고 2개의 클래스를 추가해줍니다

CustomApiException.java 전체코드

public class CustomApiException extends RuntimeException{
	
	private static final long serialVersionUID = 1L;
	
	public CustomApiException(String message) {
		super(message);
	}
}

ControllerExceptionHanlder.java 전체코드

@RestController
@ControllerAdvice
public class ControllerExceptionHanlder {
 
	//try catch문을 통해 커스텀 예외처리
	@ExceptionHandler(CustomApiException.class)
	public ResponseEntity<?> apiException(CustomApiException e) {
		System.out.println(e.getMessage());
		return ResponseEntity.badRequest().body(e.getMessage());
	}
	
    //try catch문 없이 전역 예외처리
	 @ExceptionHandler(Exception.class) 
	 public ResponseEntity<?> globalException(Exception e) { 
		 System.out.println(e.getMessage()); 
		 return ResponseEntity.badRequest().body("예상치 못한 오류가 발생하였습니다 관리자에게 문의하세요"); 
	}
	
	
	@ExceptionHandler(SQLException.class)
	public ResponseEntity<?> sqlException(SQLException e) {
		System.out.println(e.getMessage());
		return ResponseEntity.badRequest().body("데이터베이스와 통신에 실패하였습니다");
	}
	
	@ExceptionHandler(UnexpectedRollbackException.class)
	public ResponseEntity<?> rollbackException(UnexpectedRollbackException e) {
		System.out.println(e.getMessage());
		return ResponseEntity.badRequest().body("데이터 처리를 정상적으로 완료하지 못했습니다");
	}
	
	@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
	public ResponseEntity<?> sqlintException(SQLIntegrityConstraintViolationException e) {
		System.out.println(e.getMessage());
		return ResponseEntity.badRequest().body("무결성 제약 위반이 발생하였습니다");
	}
	
	
}

@ControllerAdvice란 @Controller나 @RestController에서 발생한 예외를 한 곳에서
관리하고 처리할 수 있게 도와주는 어노테이션입니다
우리는 try catch구문을 통해 우리가 커스텀한 Exception을 사용할수 있고 이때
handler에서 해당 예외발생시 어떤식으로 처리할지에 대한 로직을 구현할수있습니다
예를 들어 Service단에 try catch문을 통해 예외발생시 CustomApiException이
처리하도록 넘겨주면 우리는 body에 우리가 원하는 메시지를 담아줄수 있습니다

하지만 보통 예외가 발생하면 그에 대한 처리를 해야하므로 어떠한 상황에서 어떤
예외가 터졌는지를 로그를 기록하는게 좋으며 이러한 방법으로 모든 Exception을
처리하려면 try catch문이 끝없이 반복되므로 좋은 방법이 아닙니다
따라서 try catch문 없이 전역에서 발생하는 Exception을 처리할수 있도록
해줘야 합니다

Exception.class는 모든 예외클래스의 최상위 클래스로 ExceptionHandler로 설정하면
모든 예외에 대한 처리를 이 안에서 하게 됩니다 다만 Exception클래스 자체는
checked exception이고 이 클래스의 자식인 RuntimeException클래스가
Unchecked exception이기 때문에 둘 다 한번에 처리할지 나눠서 처리할지에
따라 사용하면 됩니다

그 자식들중에 SQLException.class가 존재하는데 보통 데이터베이스와의 통신에서
문제가 있을경우 발생하는 예외이며 이 SQLException의 자식클래스로
SQLIntegrityConstraintViolationException가 존재합니다
무결성 제약 조건이 위반되었을때 발생하는 예외이며 UnexpectedRollbackException은
@Transaction을 통해 롤백이 발생할 경우 나타나는 예외입니다

이런식으로 최상위 클래스와 그 자식 클래스를 같이 명시하게 되면 항상 최상위 클래스인
Exception클래스가 처리하는것이 아닌 handler가 존재하는 경우는 같은 클래스에서
처리하고 존재하지 않을시에만 그 상위 클래스가 처리하게 됩니다

이런식으로 내가 원하는 특정 클래스들을 등록해두고 예외발생시 클라이언트에게 어떠한
데이터를 던져줄지를 미리 설정해줄수 있습니다. try catch문도 필요없고 로그로 기록해
두기도 편하기 때문에 상위 클래스 몇개만 등록해둬도 대부분의 예외를 쉽게
처리할수있습니다 이제 다시 운영중인 매장이 아닌 매장 관리자 페이지에 접속해봅시다

오류메시지를 경고창으로 띄우고 강제로 다른 화면으로 이동하게 해주려면 API컨트롤러와
일반 컨트롤러의 예외처리를 따로 해주시면 됩니다

profile
이메일 : ehfvndcjstk@naver.com

0개의 댓글