Project/배달 주문 관리 플랫폼

[Spring Boot] 배달 플랫폼 배송지 도메인 설계: Service (실시간 권한 검증과 비즈니스 로직)

Kr1 2026. 5. 4. 01:46
  • 배송지 도메인의 핵심 비즈니스 로직을 담당하는 AddressServiceV1.java를 살펴보겠습니다. 단순히 CRUD만 하는 것을 넘어, 보안과 사용자 편의성을 어떻게 챙겼는지 중점적으로 다뤄보겠습니다.

 

 

💻 실제 코드 (AddressServiceV1.java)

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션 적용
public class AddressServiceV1 {

    private final AddressRepository addressRepository;
    private final UserRepository userRepository; 
    
    /**
     * 매 요청 시 DB 권한 실시간 재검증
     */
    private void validateUserRoleFromDB(UUID userId, UserRole tokenRole) {
        UserEntity user = userRepository.findById(userId)
                .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

        if (user.getIsDeleted()) {
            throw new CustomException(ErrorCode.USER_NOT_FOUND);
        }
        
        // 토큰의 권한과 실제 DB의 권한이 다르면 차단!
        if (user.getUserRole() != tokenRole) {
            log.warn("[Security] Role mismatch for User: {}. Token: {}, DB: {}", userId, tokenRole, user.getUserRole());
            throw new CustomException(ErrorCode.ACCESS_DENIED);
        }
    }

    /**
     * 배송지 생성
     */
    @Transactional // 쓰기 작업이므로 트랜잭션 오버라이딩
    public ResCreateAddressDtoV1 createAddress(ReqCreateAddressDtoV1 request, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
        
        // 새 배송지를 '기본'으로 설정했다면, 기존 기본 배송지를 false로 변경
        if (Boolean.TRUE.equals(request.getIsDefault())) {
            handleDefaultAddress(userId);
        }

        AddressEntity address = request.toEntity(userId);
        address.markCreatedBy(userId);

        AddressEntity savedAddress = addressRepository.save(address);
        
        return ResCreateAddressDtoV1.from(savedAddress, "배송지가 성공적으로 생성되었습니다.");
    }

    /**
     * 본인의 배송지 목록 조회
     */
    public Page<ResGetAddressDtoV1> getMyAddresses(UUID userId, UserRole role, String alias, Pageable pageable) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
  
        Page<AddressEntity> addresses;
        // 동적 쿼리를 대체하는 조건 분기 로직
        if (alias != null && !alias.isBlank()) {
            addresses = addressRepository.findAllByUserIdAndAliasContainingAndIsDeletedFalse(userId, alias, pageable);
        } else {
            addresses = addressRepository.findAllByUserIdAndIsDeletedFalse(userId, pageable);
        }
        
        return addresses.map(ResGetAddressDtoV1::from); // 엔티티 -> DTO 변환
    }

    /**
     * 배송지 수정
     */
    @Transactional
    public ResGetAddressDtoV1 updateAddress(UUID addressId, ReqUpdateAddressDtoV1 request, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행

        AddressEntity address = addressRepository.findByIdAndIsDeletedFalse(addressId)
                .orElseThrow(() -> new CustomException(ErrorCode.ADDRESS_NOT_FOUND));

        // [인가] 본인 확인 로직
        if (!address.getUserId().equals(userId)) {
            throw new CustomException(ErrorCode.ADDRESS_NOT_OWNER);
        }
        
        // 새롭게 기본 배송지로 설정하는 경우 기존 기본 배송지 해제
        if (Boolean.TRUE.equals(request.getIsDefault()) && !address.getIsDefault()) {
            handleDefaultAddress(userId);
        }
        
        // 엔티티 내부의 비즈니스 메서드 호출 (더티 체킹 발생)
        address.updateAddress(
                request.getAlias(),
                request.getAddress(),
                request.getDetail(),
                request.getZipCode(),
                request.getIsDefault()
        );
        
        address.markUpdatedBy(userId); // Audit(수정자) 업데이트

        return ResGetAddressDtoV1.from(address, "배송지 정보가 성공적으로 수정되었습니다.");
    }

    /**
     * 배송지 삭제 (Soft Delete)
     */
    @Transactional
    public void deleteAddress(UUID addressId, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행

        AddressEntity address = addressRepository.findByIdAndIsDeletedFalse(addressId)
                .orElseThrow(() -> new CustomException(ErrorCode.ADDRESS_NOT_FOUND));

        // [인가] 본인 확인 로직
        if (!address.getUserId().equals(userId)) { 
            throw new CustomException(ErrorCode.ADDRESS_NOT_OWNER);
        }

        address.softDelete(userId); // 실제 데이터 삭제가 아닌 상태값 변경
    }
    
    // 기존 기본 배송지를 해제하는 편의 메서드
    private void handleDefaultAddress(UUID userId) {
        addressRepository.findByUserIdAndIsDefaultTrueAndIsDeletedFalse(userId)
                .ifPresent(existingDefault -> existingDefault.setDefault(false));
    }
}

 

🤔 왜 이렇게 짰는가? (설계 의도와 이유)

왜 JWT가 있는데 굳이 validateUserRoleFromDB를 만들어서 매번 DB를 조회했을까?

  • 이 프로젝트에서 가장 깊게 고민한 보안과 정합성 문제입니다. JWT(JSON Web Token)는 한 번 발급되면 만료되기 전까지는 상태를 서버가 제어할 수 없는 Stateless한 특성이 있습니다.
  • 만약 악성 유저가 차단되거나 권한이 강등되었는데, 토큰 만료 시간이 30분이나 남아있다면 그 30분 동안은 맘대로 배송지를 조작하고 주문을 넣을 수 있습니다. 결제와 직결되는 배송지 도메인에서는 이 구멍을 막는 게 중요했습니다.
  • 그래서 모든 API 메서드 최상단에 validateUserRoleFromDB를 배치하여, 토큰이 유효하더라도  "현재 DB 상에서 삭제된 유저인지, 혹은 권한이 변조되지 않았는지"를 실시간으로 크로스 체크하도록 방어 로직을 짰습니다.
현재는 토큰의 권한이 최신 상태와 일치하는지 보장하기 위해 매 요청마다 DB에서 사용자 정보를 재조회하는 방식으로 구현하였다. 다만, 트래픽 증가 시 불필요한 DB 조회가 반복되어 병목(특정 구간에서 처리량이 제한되어 전체 성능이 저하되는 현상)이 발생할 수 있다. 따라서 차후에는 사용자 권한 및 상태 정보를 Redis에 캐싱(자주 조회되는 데이터를 메모리에 저장하여 빠르게 조회하는 방식)하고, 권한 변경 또는 탈퇴 시 캐시를 무효화(기존 캐시 데이터를 삭제하거나 갱신하여 최신 상태를 반영하는 것)하는 전략을 통해 DB 부하를 줄이면서도 데이터의 최신 상태를 유지하도록 개선할 수 있다.

handleDefaultAddress 로직의 필요성

  • 배송지는 사용자당 기본 배송지가 하나만 유지되어야 합니다. 따라서 사용자가 새 배송지를 기본 배송지로 등록하거나 기존 배송지를 기본 배송지로 수정하는 경우, 기존에 기본 배송지로 설정되어 있던 배송지는 자동으로 `false`로 변경되어야 합니다. 
  • 이를 위해 `findByUserIdAndIsDefaultTrueAndIsDeletedFalse` 메서드로 해당 사용자의 삭제되지 않은 기본 배송지를 조회하고, 존재할 경우 `setDefault(false)`를 호출해 기존 기본 배송지 설정을 해제했습니다.
  • 이 처리를 프론트엔드에 맡기면 요청 누락이나 동시 요청 상황에서 기본 배송지가 여러 개 생기는 데이터 꼬임이 발생할 수 있습니다.
  • 따라서 서비스 계층에서 `@Transactional`로 묶어 기존 기본 배송지 해제와 새 기본 배송지 저장/수정을 하나의 작업 단위로 처리했습니다. 

권한 가드(Guard) 2단계: 본인 소유 확인(!address.getUserId().equals(userId))

  • 유효한 유저라고 할지라도, 남의 배송지 ID(addressId)를 파라미터로 조작해서 삭제 요청을 보낼 수 있습니다. 이를 막기 위해 수정/삭제 로직에서는 조회해 온 address 엔티티의 userId와 요청을 보낸 사람의 userId가 일치하는지 확인하는 인가(Authorization) 처리를 잊지 않았습니다.
JPA는 엔티티를 조회하면 영속성 컨텍스트라는 메모리 공간에 저장하여 관리한다. 한 번 조회된 엔티티는 메모리에 올라가 재사용되며, 해당 엔티티의 필드 값에 접근하는 것은 단순한 메모리 연산으로 처리된다. 따라서 추가적인 DB 조회가 발생하지 않는다. 또한 본 코드에서는 사용자 정보를 연관관계(Entity)가 아닌 UUID 값으로 보관하고 있기 때문에, 지연 로딩(LAZY)으로 인한 프록시 객체 생성이나 추가 쿼리 발생 없이 안전하게 비교 연산을 수행할 수 있다.

페이지네이션(Pageable)과 map()의 활용

  • 배송지가 수십 개 쌓일 수 있으므로 전체를 한 번에 불러오지 않고 Pageable을 받아 페이징 처리를 했습니다.  
  • 또한 Page 객체 내부의 Entity를 반복문 대신 map()을 사용해 DTO로 변환하여, 각 요소를 변환하는 로직을 간결하게 처리했습니다. (map()은 컬렉션의 각 요소를 변환하여 새로운 형태로 반환하는 함수형 연산입니다.)

수정 로직(updateAddress)에 save()가 없는 이유 (더티 체킹)

  • 코드를 자세히 보면 수정을 완료하고 addressRepository.save()를 호출하는 코드가 없습니다.
  • 메서드 위에 @Transactional이 걸려 있기 때문에, JPA의 영속성 컨텍스트가 엔티티의 변경을 감지합니다.
  •  address.updateAddress(...)를 통해 객체의 값만 바꿔주면, 메서드가 종료될 때 JPA가 알아서 UPDATE 쿼리를 DB에 날려줍니다.
  • 이를 '더티 체킹(Dirty Checking)'이라고 부르며, 객체지향적인 코드 작성을 돕는 JPA의 핵심 기능입니다.

 

💡 정리 

  •  JWT의 한계를 보완한 보안 설계: "WT의 Stateless한 단점을 인지하고, 결제/배송지 등 민감한 도메인에 validateUserRoleFromDB 메서드를 도입하여 실시간으로 DB 권한을 재검증하는 하이브리드 보안 구조를 설계했습니다. (이로 인한 DB 병목 문제는 인지하고 있으며, 향후 Redis 캐싱으로 풀어나갈 수 있을 것 같습니다.)
  • 꼼꼼한 인가(Authorization) 처리: 단순히 토큰 인증(Authentication)에 그치지 않고, address.getUserId().equals(userId)와 같이 실제 자원의 소유자인지 확인하는 인가 로직을 직접 구현해 BOLA(Broken Object Level Authorization) 취약점을 방어했습니다.
  • 효율적인 트랜잭션 스코프(적용 범위): 클래스 레벨에 @Transactional(readOnly = true)를 설정하여 조회 성능을 최적화하고, 데이터 변경이 일어나는 create, update, delete 메서드에만 @Transactional을 별도로 적용하는 방식으로 불필요한 쓰기 트랜잭션을 방지했습니다. 또한 readOnly 트랜잭션을 통해 향후 마스터-슬레이브 구조(읽기/쓰기 DB를 분리하여 조회는 Slave, 변경은 Master에서 처리하는 구조)로 확장할 수 있는 기반을 고려했습니다. 
  • Page DTO 매핑: Spring Data JPA의 Page 인터페이스가 제공하는 .map() 함수를 적극 활용하여, 반복문 없이 깔끔하게 엔티티 페이징 결과를 DTO 페이징 결과로 변환했습니다.
  • 더티 체킹(Dirty Checking)의 활용: "엔티티를 수정할 때 불필요한 save() 호출을 생략하고, @Transactional 환경 내에서 엔티티 객체의 상태만 변경하여 JPA의 더티 체킹 메커니즘을 통해 자연스럽게 UPDATE 쿼리가 발생하도록 객체지향적으로 코드를 작성했습니다.