본문 바로가기
What I Learned

[NBCAMP | Spring 6기] 83일차 TIL + 자바 컬렉션 프레임워크, 인가, 커스텀 어노테이션, @PreAuthorize

by ㅇ달빛천사ㅇ 2024. 10. 20.
728x90

👈  이전글

[NBCAMP | Spring 6기] 82일차 TIL + Stream, Slack App, postMessage


KDT 실무형 스프링 백엔드 엔지니어 양성과정 6기

 


🗝 오늘의 학습 키워드 : 자바 컬렉션 프레임워크 인가 커스텀 어노테이션 @PreAuthorize



📖 공부한 내용 본인의 언어로 정리하기

🌞하10C

자바 컬렉션 프레임워크(List, Set, Map)

데이터를 효율적으로 저장하고 관리할 수 있게 해주는 도구

 

List

  • 순서가 유지
  • 중복 데이터 허용 O
  • 대표 클래스
    • ArrayList
    • LinkedList
  • 사용 예 : 순서가 중요한 경우
    • 대기열
    • 이력 기록

 ArrayList vs LinkedList

 


Set

  • 순서가 없음
  • 중복 허용 X
  • 대표 클래스
    • HashSet
    • TreeSet
  • 사용 예 : 중복이 없는 경우
    • 유저 ID 관리
    • 태그 관리

HashSet vs TreeSet

 


List vs Set


Map

  • 키 - 값(Key - Value) 쌍으로 데이터를 저장
  • 키는 중복 불가
  • 값은 중복 허용
  • 대표 클래스
    • HashMap
    • TreeMap
  • 사용 예 : 키 - 값 쌍으로 데이터를 저장해야 할 때
    • '이름 - 나이'
    • '상품 - 재고' 관리
  • 키를 통해서 데이터를 빠르게 조회 가능(조회 : 알고리즘 복잡도 O(1))

HashMap vs ConcurrentHashMap

여러 개의 스레드가 접근할 때, 동시성 문제가 발생할 수 있음
이럴 경우 ConcurrentHashMap 사용 권장

 

 

📗 참고 자료

 

자바 컬렉션 프레임워크 ( List, Set, Map )

Java의 컬렉션(Collection) 프레임워크데이터를 효율적으로 저장하고 관리할 수 있게 해주는 도구 List 순서가 유지됨중복 데이터 허용대표 클래스: ArrayList, LinkedList사용 예: 순서가 중요한 대기열,

llmmhh.tistory.com


🏃 내일배움캠프 팀 프로젝트 - 내일lo

💥 주특기 플러스 주차 Trello 프로젝트 트러블 슈팅

드디어 팀 프로젝트 제출일이다.

특히나 이번 팀 프로젝트는 정말 순식간에 지나간 기분이다.

코드 리뷰가 힘들었지만 배우는 것도 많고 개발 상황이 잘 정리되는 것 같아 좋았다.

여기까지는 여담이고...

 

 

제출일 인데 아직 덱 CRUD 권한 설정이나 슬랙 알람 기능 구현이 완료되지 않았다.

 

1. 멤버 역할 인가 처리

승재님께서 멤버 역할 인가처리를 위해
@PreAuthorize() 어노테이션을 사용하여 커스텀 어노테이션을 만들어 주셨다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@authService.fromAuthUser(authentication.principal) != null && " +
        "@memberAuthorizeService.hasAccessToWorkspace(@authService.fromAuthUser(authentication.principal), #workspaceId)")
public @interface WorkspaceAccessAuthorize {
}

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@authService.fromAuthUser(authentication.principal) != null && " +
        "@memberAuthorizeService.hasReadOnlyRole(@authService.fromAuthUser(authentication.principal), #workspaceId)")
public @interface WorkspaceAccessButReadOnlyAuthorize {
}

 

 

그런데 멤버 역할이 3개(워크스페이스, 보드, 읽기 전용) 이니

워크스페이스의 멤버인지 권한 확인용 1개,

워크스페이스 외 컨텐츠 생성, 수정, 삭제 허용 권한인 보드 권한 확인용 1개,

워크스페이스 수정, 삭제 허용 권한인 워크스페이스 권한 확인용 1개

총 3개의 어노테이션이 필요할 것 같은데 어노테이션이 2개 밖에 안 만들어져 있어서

내가 새로 커스텀 어노테이션을 만들었다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@memberAuthorizeService.hasMemberRole(authentication.principal, #workspaceId)")
public @interface RoleMember {
}

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@memberAuthorizeService.hasMemberRoleOverREAD_ONLY(authentication.principal, #workspaceId)")
public @interface RoleOverReadOnly {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@memberAuthorizeService.hasMemberRoleWORKSPACE(authentication.principal, #workspaceId)")
public @interface RoleWorkspace {
}

 

승재님께서는 authentication.principal로 인증 객체를 받고 User 객체로 변환하여 null인지 체크 후, 인가 처리를 했는데

인증이 성공하면 인증 객체가 null이 아니고

authentication.principal로 AuthUser 객체를 받을 때, userId도 있기 때문에 따로 User객체로 파싱하지 않고

바로 MemberAuthorizeService의 권한 검사 메서드에 AuthUser 인스턴스와 워크스페이스ID를 매개변수로 주어 권한 검사를 하였다.

@Component
@RequiredArgsConstructor
public class JwtSecurityFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final RedisUtil redisUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest httpRequest,
            @NonNull HttpServletResponse httpResponse,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {
        String authorizationHeader = httpRequest.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String jwt = jwtUtil.substringToken(authorizationHeader);

            // 로그아웃 된 토큰인지 검증 -> Redis에 저장되어 있는지 검증
            String redisKey = "logout:" + jwt;
            boolean isLogout = "logout".equals(redisUtil.get(redisKey));

            if (!isLogout) {
                try {
                    Claims claims = jwtUtil.extractClaims(jwt);
                    Long userId = Long.valueOf(claims.getSubject());
                    String email = claims.get("email", String.class);
                    UserRole userRole = UserRole.of(claims.get("userRole", String.class));

                    if (SecurityContextHolder.getContext().getAuthentication() == null) {
                        AuthUser authUser = new AuthUser(userId, email, userRole);
                        JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
                        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                    }
                    
// 이하 코드 생략
}

 

 

그리고 하나 더, MemberAuthorizeService에서 권한 체크를 할 때

findMemberByWorkspaceId() 메서드에서 workspaceRepository.findById() 메서드로 쿼리를 한번 날리고

memberRepository.findAcceptedMember() 메서드로 쿼리를 또 한번 날리는 것이 비효율적을 보였다.

@Service
@RequiredArgsConstructor
public class MemberAuthorizeService {
    private final MemberRepository memberRepository;
    private final WorkspaceRepository workspaceRepository;
    // 특정 workspaceId에 대한 Member 객체를 Optional 반환하는 공통 메서드
    private Optional<Member> findMemberByWorkspaceId(User user, Long workspaceId) {
        Workspace workspace = workspaceRepository.findById(workspaceId).orElseThrow(
                ()-> new ApiException(ErrorStatus.NOT_FOUND_WORKSPACE)
        );
        return memberRepository.findAcceptedMember(user,workspace, InvitationStatus.ACCEPT);
    }

    // workspace 접근할 수 있는지 검증
    public boolean hasAccessToWorkspace(User user, Long workspaceId) {
            // 공통 메서드를 이용하여 Member 존재 여부를 확인
        return findMemberByWorkspaceId(user, workspaceId).isPresent();
    }

    // 워크스페이스에 대한 역할이 READ_ONLY인지 검증
    public boolean hasReadOnlyRole(User user, Long workspaceId) {
        return findMemberByWorkspaceId(user, workspaceId)
                .map(member -> member.getMemberRole() != MemberRole.READ_ONLY)
                .orElse(false);
        }

 

그래서 서비스 클래스의 코드를 변경하고

@Service
@RequiredArgsConstructor
public class MemberAuthorizeService {
    private final MemberRepository memberRepository;

    // workspace 접근할 수 있는지 검증
    public boolean hasMemberRoleWORKSPACE(AuthUser authUser, Long workspaceId) {
        // 공통 메서드를 이용하여 Member 존재 여부를 확인
        return this.memberRepository.hasMemberRoleWORKSPACE(authUser.getId(), workspaceId);
    }

    // 워크스페이스에 대한 역할이 BOARD 이상인지 검증
    public boolean hasMemberRoleOverREAD_ONLY(AuthUser authUser, Long workspaceId) {
        return this.memberRepository.hasMemberRoleOverREAD_ONLY(authUser.getId(), workspaceId);
    }

    // 워크스페이스에 대한 역할이 READ_ONLY인지 검증
    public boolean hasMemberRole(AuthUser authUser, Long workspaceId) {
        return this.memberRepository.hasMemberRole(authUser.getId(), workspaceId);
    }
}

 

QueryDSL로 쿼리를 작성하여 한 번의 쿼리로 권한을 확인할 수 있도록 하였다.

// 멤버 권한 확인(WORKSPACE)
    @Override
    public boolean hasMemberRoleWORKSPACE(Long userId, Long workspaceId){
        return Boolean.TRUE.equals(jpaQueryFactory
                .select(member.count().eq(1L))
                .from(member)
                .where(member.workspace.id.eq(workspaceId)
                        .and(member.user.id.eq(userId))
                        .and(member.invitationStatus.eq(InvitationStatus.ACCEPT))
                        .and(member.memberRole.eq(MemberRole.WORKSPACE))
                )
                .fetchFirst());

    }


    // 워크스페이스 ID와 유저ID로 멤버 권한 확인(BOARD, WORKSPACE)
    @Override
    public boolean hasMemberRoleOverREAD_ONLY(Long userId, Long workspaceId) {
        return Boolean.TRUE.equals(jpaQueryFactory
                .select(member.count().eq(1L))
                .from(member)
                .where(member.workspace.id.eq(workspaceId)
                        .and(member.user.id.eq(userId))
                        .and(member.invitationStatus.eq(InvitationStatus.ACCEPT)
                        .and(member.memberRole.ne(MemberRole.READ_ONLY))
                        )
                )
                .fetchFirst());
    }


    // 워크스페이스 ID와 유저ID로 멤버 권한 확인(BOARD, WORKSPACE, READ_ONLY)
    @Override
    public boolean hasMemberRole(Long userId, Long workspaceId) {
        return Boolean.TRUE.equals(jpaQueryFactory
                .select(member.count().eq(1L))
                .from(member)
                .where(member.workspace.id.eq(workspaceId)
                        .and(member.user.id.eq(userId))
                        .and(member.invitationStatus.eq(InvitationStatus.ACCEPT))
                )
                .fetchFirst());
    }

 

그리고 각 컨트롤러의 메서드에 맞게 어노테이션을 붙여 인가를 하였다.

@Validated
@RestController
@RequiredArgsConstructor
public class DeckController {

    private final DeckService deckService;

    /**
     * 덱 생성
     * @param boardId : 덱를 생성할 보드 ID
     * @param deckName : 생성할 덱 이름
     * @return ApiResponse : message - "덱 생성 성공"/ Status Code - 200 / date - 생성된 덱 정보를 바인딩한 DeckResponse 객체
     */
    @RoleOverReadOnly
    @PostMapping("/workspaces/{workspaceId}/boards/{boardId}/decks")
    public ResponseEntity<ApiResponse<DeckCreateResponse>> createDeck(
            @PathVariable(name = "workspaceId") Long workspaceId,
            @PathVariable(name = "boardId") Long boardId,
            @RequestBody @NotBlank String deckName
                                                        ) {
        return ResponseEntity
                .status(200)
                .body(
                        ApiResponse.onSuccess(
                                this.deckService.createDeck(boardId, deckName)
                        )
                );
    }

    /**
     * 덱 전체 조회
     * @param request : 덱 조회 조건(워크스페이스 ID, 보드ID, 페이징 page, 페이징 크기)을 바인딩 한 DeckFinaAllRequest 객체
     * @return ApiResponse : message - "덱 전체 조회 성공"/ Status Code - 200 / data - 덱 조회 결과를 바인딩하여 페이징한 Page<DeckResponse> 객체
     */
    @RoleMember
    @GetMapping("/workspaces/{workspaceId}/boards/{boardId}/decks")
    public ResponseEntity<ApiResponse<Page<DeckResponse>>> getDecks(
            @PathVariable(name = "workspaceId") Long workspaceId,
            DeckFindAllRequest request
    ) {
        return ResponseEntity
                .status(200)
                .body(
                        ApiResponse.onSuccess(
                                this.deckService.getDecks(request)
                        )
                );
    }
    
// 이하 코드 생략

}

 

커스텀 어노테이션에 대해서는 잘 모르는 것이 많았는데

이번에 승재님 덕분에 @PreAuthorize 어노테이션에 대해서도 알게되었고

옵션을 어떻게 주는지도 조금 더 알게 되어서 매우 유익했다.

 

그래도 아직 커스텀 어노테이션에 대해 모르는 것이 많아서 많이 더 배우고 싶다.

 

2. 이벤트 감지 및 슬랙 알람 구현 실패

과제 제출이 12시까지 였는데 내가 팀장이다 보니

팀원 별 시연 영상 찍은 거 합치고, README 파일 작성하고 하다보니

컨텐츠 생성, 수정, 삭제 이벤트 감지 후, 슬랙 알람 요청을 보내는 코드를 구현하지 못하였다.

 

팀원분들에게 도움을 구했어야 했나... 싶기도 하고

좀 더 계획을 더 잘 세워서 빠릿빠릿하게 구현을 했어야 했는데... 하는 생각도 들었다.

도전 과제도 아니었는데 완성을 못했다니...

내가 팀장이었는데 도전과제까지 진도를 못 나가서 너무 미안했다.

승재님께서 끝까지 캐싱을 하기 위해 혼자서 달리셨는데

발표까지 완성하지 못한 프로젝트를 시도한 선에서 잘 설명해 주셔서 너무 감사한 마음이 들었다.

열심히 공부하고 프로젝트를 많이 하다보면 나아지겠지...?

나를 믿고 시간을 믿고 포기하지 말고 계속 나아가야겠다.


🗨️ 무엇을 새롭게 알았는지

  • Java Colleciton Framework
    • List
      • ArrayList
      • LinkedList
    • Set
      • HashSet
      • TreeSet
    • Map
      • HashMap
      • TreeMap
  • 인가
  • 커스텀 어노테이션
  • @PreAuthorize



💭 내일 학습할 것은 무엇인지

  • 특강 듣기
  • 면접 스터디 준비
  • 강의 듣기





👉  다음글







 

728x90