@PreAuthorize로 인가 로직과 비즈니스 로직 분리하기 (인터페이스 활용 & 쿼리 개선)

양말고양이·2025년 1월 12일

프로젝트 이슈 🐟

목록 보기
2/7

1. 기존 코드

기존 코드에서는 전달 받은 엔티티가 현재 로그인한 사용자의 소유가 맞는지 확인하는 로직을 유틸리티 클래스로 구현했습니다.


@UtilityClass
public class AuthUtils {
	public Long getCurrentUserId() { //로그인한 사용자 정보 가져오기
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (authentication == null) {
			return null;
		}
		return Long.valueOf(authentication.getPrincipal().toString());
	}

    private boolean isOwnedEntity(OwnedEntity entity) { 
    	//현재 로그인한 사용자의 ID와 엔티티의 ownerId 비교
        return Objects.equals(entity.getOwnerId(), getCurrentUserId());
    }

    public void checkOwnedEntity(OwnedEntity entity) {
    	//예외 처리
        if (!isOwnedEntity(entity)) {
            throw new ForbiddenApplcationException("해당 리소스에 대한 권한이 없습니다.");
        }
    }
}

Lombok@UtilityClass 어노테이션을 붙였기 때문에 생성자는 private으로 생성되며 모든 메서드가 static으로 만들어집니다.


유틸리티 클래스는 왜 private 생성자를 가져야할까?
유틸리티 클래스는 상태를 가지지 않고 기능만을 제공하는 클래스로 인스턴스를 생성할 필요가 없습니다. 따라서 유틸리티 클래스의 인스턴스를 만드는 걸 컴파일 타임에 방지하여, 클래스의 목적에 부합하는 사용을 강제하기 위해 private으로 생성자를 선언합니다.


그리고 엔티티의 권한을 확인하기 위한 OwnedEntity라는 인터페이스가 있습니다.

모든 엔티티가 상속받고 있는 BaseEntity(createdAt, modifiedAt)가 이 인터페이스를 implements하고 있기 때문에, 모든 엔티티에서 ownerId를 설정하여 사용할 수 있습니다.


//OwnedEntity
public interface OwnedEntity {
    Long getOwnerId();
}



//BaseEntity -> OwnedEntity 상속
@Getter
@MappedSuperclass
public abstract class BaseEntity implements OwnedEntity {
    @CreationTimestamp
    @Column(nullable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(nullable = false)
    private LocalDateTime modifiedAt;
}



//User -> BaseEntity 상속, ownerId 설정
public class User extends BaseEntity {
.
.
	@Override
	public Long getOwnerId() {
		return this.userId;
	}
}

그러면 서비스 단에서는 아래와 같이 사용할 수 있습니다.


	@Transactional
	public UserGoalResponse updateCurrentUserGoal(Long userId, GoalChangeRequest request) {
		User user = getUserById(userId);
		AuthUtils.checkOwnedEntity(user);
		user.changeGoal(request.goal());
		return UserGoalResponse.of(user.getGoal());
	}

하지만 이렇게 구현하면 인가 로직과 비즈니스 로직이 강하게 결합된다는 단점이 있었고, 더 객체지향적으로 리팩토링하기 위한 방법을 찾아야했습니다.




2. @PreAuthorize, @PostAuthorize, AOP

해결 방법으로 스프링 시큐리티의 @PreAuthorize, @PostAuthorize 어노테이션과 AOP 방식을 생각했습니다.

셋 중 무엇을 사용할 지, 게시글(엔티티명은 Record)을 수정하는 메서드를 예시로 고민해봤습니다.


2.1 @PreAuthorize를 사용할 경우

메서드 호출 전에 인가 검사를 수행하는 어노테이션입니다.

@PreAuthorize에 정의된 조건이 만족되지 않으면, 메서드가 호출되지 않고 AccessDeniedException이 발생합니다.


예를 들어 아래와 같이 사용할 수 있습니다.

	@PreAuthorize("@recordAccessHandler.isRecordOwner(#recordId)")
	@Transactional
	public RecordModifyResponse modifyRecord(Long userId, Long recordId, RecordModifyRequest request) throws IOException {
		User user = userService.getUserById(userId);
		Record record = getRecordById(recordId);
        .
        .
        
	}

파라미터로 엔티티를 받게 수정할 수 없을까 고민했지만 메서드의 개수도 많아지고 흐름이 부자연스러워질 것 같아 파라미터로 recordId를 받게 구현했습니다.

그런데 그렇게 하면 현재 로그인 중인 사용자의 아이디와, 파라미터로 받은 recordId에 해당하는 record를 조회하여 ownerId를 비교해야 했습니다.

아래와 같이 recordAccessHandler 클래스에서 recordService를 이용해 조회하는 방법 말고는 다른 방법을 찾지 못했습니다... 😿


@Component
@RequiredArgsConstructor
public class RecordAccessHandler {

    private final RecordService recordService;

    public boolean isRecordOwner(Long recordId) {
        Long currentUserId = AuthUtils.getCurrentUserId();
        Record record = recordService.getRecordById(recordId);
        return Objects.equals(record.getOwnerId(), currentUserId);
    }
}

이렇게 구현할 경우 DB에서 엔티티를 2번 조회해야하고(핸들러에서 & 서비스 로직에서),
엔티티별로 핸들러를 만들어줘야 한다는 단점이 있지만,
비즈니스 로직을 실행하기 전에 검증할 수 있다는 큰 장점이 있었습니다.


2.2 @PostAuthorize를 사용할 경우

메서드 호출 후에 인가 검사를 수행합니다.

@PostAuthorize를 통해 반환된 객체인 returnObject.ownerIdprincipal.id를 비교할 수 있습니다.

그런데 원래 서비스 메서드에서는 Response DTO를 반환하므로 반환값을 기준으로 권한을 검증하는 로직이 적합하지 않기 때문에, getRecordById 메서드에 @PostAuthorize를 붙여야 했습니다.


@Transactional
@PostAuthorize("returnObject.ownerId == principal.id")
public Record getRecordById(Long recordId) {
    return recordRepository.findById(recordId)
        .orElseThrow(() -> new EntityNotFoundException("Record not found"));
}

하지만 서비스에서 특정 사용자가 다른 사용자의 게시글도 조회할 수 있기 때문에 적용하기 어려울 것이라고 판단했습니다.

또한 비즈니스 로직을 먼저 수행한 후 검증이 이루어지는 점, 예외가 발생했을 경우 롤백을 처리하기 위해 @Transactional과의 순서를 조정해야하는 점도 단점이었습니다.


2.3 AOP

AOP를 활용하면 메서드 호출 전후 또는 예외 발생 시점 등 다양한 위치에서 커스텀한 인가 로직을 실행할 수 있습니다.

대신 그만큼 과정이 좀 복잡한데, 공부할 겸 구현해봤습니다.


AOP에는 Aspect, Pointcut 등등 여러 개념이 있지만 이건 다른 포스트에서 정리하고 있어 넘어가고,

먼저 커스텀 어노테이션을 만들어 주겠습니다.

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

}

그리고 Pointcut을 설정해줍니다.

@Before("@annotation(com.example.security.CheckOwnership) && args(entity,..)")

이렇게 간단하게 표현할 수도 있지만,
명시적으로 짜고 싶어서 따로 메서드를 만들어 정의해줬습니다.

@CheckOwnership 어노테이션이 붙어있는지, 파라미터 리스트에 엔티티가 포함되어있는지 확인합니다.

따라서 아래와 같이 Aspect 클래스를 만들어봤습니다.

@Aspect
@Component
public class AuthorizationAspect {
	@Pointcut("@annotation(CheckOwnedEntity) && args(entity, ..)")
	private void checkOwnedEntityPointCut(OwnedEntity entity) {
	}

	@Before(value = "checkOwnedEntityPointCut(entity)", argNames = "entity")
	public void checkOwnedEntity(OwnedEntity entity) {
		if (!isOwnedEntity(entity)) {
            throw new ForbiddenApplicationException("해당 리소스에 대한 권한이 없습니다.");
        }
	}

    private boolean isOwnedEntity(OwnedEntity entity) {
        return Objects.equals(entity.getOwnerId(), AuthUtils.getCurrentUserId());
    }
}

하지만 위 코드는 파라미터로 엔티티를 넘겨주는 방식으로 구현해본 것이고...

로직에서 사용할 수 있게 파라미터로 id를 넘겨주도록 다시 로직을 수정한다면 @PreAuthorize와 비슷한 장단점을 가질 것입니다.

커스텀까지 하기에는 인가 로직이 그렇게 복잡하지 않기도 하고, 결국 비슷한 역할을 할거라면 AOP보다는 좀 더 간결하게 사용할 수 있는 @PreAuthorize가 적합할 것이라고 생각했습니다.




3. 어떤 방식을 선택할까

위에서 적은 장단점을 바탕으로 @PreAuthorize를 통한 구현을 선택했습니다.

사실 아직까지 로직 간의 결합도가 높은 AuthUtils 코드 vs 상대적으로 구현이 복잡하고 중복 쿼리가 발생하는 @PreAuthorize 중 뭐를 선택하는게 좋을지 잘 모르겠지만........

서비스를 운영한 지 얼마 안되어 데이터의 크기가 크지 않아 성능에 큰 문제가 없을 것으로 판단되고,
객체지향적 코드를 중시하고 싶어서 우선 결합도를 낮추는 방식을 선택했습니다.

대신 엔티티별로 핸들러를 만들어줘야 한다는 점과 엔티티를 두 번 조회해야한다는 점을 개선해야했습니다.


3.1 엔티티별 핸들러 생성 → 인터페이스 활용하기

코드 중복을 줄이고 확장성과 유지보수성을 높이기 위해 인터페이스를 활용할 것입니다.


상위에 EntityAccessHandler를 만들어준 후,


@Component
public interface EntityAccessHandler {
	boolean isOwner(Long entityId);

	default boolean hasSameOwnerId(OwnedEntity entity) {
		Long currentUserId = AuthUtils.getCurrentUserId();
		return Objects.equals(currentUserId, entity.getOwnerId());
	}
}

아래와 같이 엔티티별로 세부적으로 구현하여 확장할 수 있도록 했습니다.


@Component
@RequiredArgsConstructor
public class RecordAccessHandler implements EntityAccessHandler {

	private final RecordService recordService;

	@Override
	public boolean isOwner(Long recordId) {
		Record record = recordService.getRecordById(recordId);
		return hasSameOwnerId(record);
	}
}

3.2 중복 조회로 인한 불필요한 쿼리 개선하기

당장 중복 조회 문제 자체를 완전히 해결할 수는 없을 것 같아, DB 조회 시 실행되는 쿼리를 줄여보기로 했습니다.

로직을 위해서는 해당 문제 풀이의 userId만 필요한데, 기존 코드로는 User 테이블과 조인한 후 Record 객체 전체를 조회하는 쿼리가 나가게 됩니다.

Hibernate: 
    select
        r1_0.record_id,
        r1_0.created_at,
        .
        .
        u1_0.username,
        u1_0.withdraw 
    from
        record r1_0 
    join
        user u1_0 
            on u1_0.user_id=r1_0.user_id 
            and (u1_0.withdraw = 0) 
    where
        r1_0.record_id=?

아래와 같이 JQPL전체 엔티티 대신 필요한 필드만 조회할 수 있게 @Query 어노테이션으로 쿼리를 작성했습니다.


	//RecordRepository.java
	@Query("SELECT r.user.userId FROM Record r WHERE r.recordId = :recordId")
	Long findOwnerIdByRecordId(@Param("recordId") Long recordId);
    
    
	//RecordRepository.java    
    public Long getOwnerIdByRecordId(Long recordId) {
		return recordRepository.findOwnerIdByRecordId(recordId);
	}

이에 맞게 핸들러의 메서드도 수정해줬습니다.
OwnedEntity는 프로젝트에서 크게 필요하지 않게 되었으니 삭제해야겠네요.


	@Override
	public boolean isOwner(Long recordId) {
		Long ownerId = recordService.getOwnerIdByRecordId(recordId);
		return isSameWithCurrentUserId(ownerId);
	}

메서드를 좀 추가해야하긴했지만, 그 결과 첫번째로 나가는 쿼리가 아래와 같이 줄어들게 되었습니다.

Hibernate: 
    select
        r1_0.user_id 
    from
        record r1_0 
    where
        r1_0.record_id=?

사용자 엔티티그룹 엔티티에도 같은 방식을 적용해줬습니다.

profile
백엔드 개발자입니다

0개의 댓글