순간을 성실히, 화려함보단 꾸준함을

[JPA] could not initialize proxy - no Session 발생 본문

나의 개발 메모장

[JPA] could not initialize proxy - no Session 발생

폭발토끼 2022. 1. 6. 00:19

안녕하세요.

오늘도 역시 토이 프로젝트를 진행하다가 발생한 문제점을 가져와서 해결했던 점을 포스팅 해보도록 하겠습니다.

먼저 구조부터 설명하자면, 현재 로그인 기능을 구현하기 위해서 HttpSession 에 회원의 정보들을 담을려고 합니다.
그리고 이 session에 담긴 '권한'으로 이 회원이 어디까지 '인가'될 수 있는지 판단해 주는 로직을 구현하려고 하는 중입니다.

맴버 Entity

package boomrabbit.logintest.mvc.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.HashMap;
import java.util.Map;

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name="ID")
    private Long id;

    /**
     * 아이디
     */
    private String memberId;

    /**
     * 비밀번호
     */
    private String password;

    /**
     * Member 권한 (회원가입시 처음은 무조건 LEVEL1)
     */
    @Enumerated(EnumType.STRING)
    @ElementCollection(fetch = FetchType.LAZY)
    @CollectionTable(name = "MEMBER_STATUS" ,
            joinColumns = @JoinColumn(name = "ID"))
    private Map<Long,MemberStatus> memberStatus = new HashMap<>();

    /**
     * 전화번호(휴대폰 번호)
     */
    private String phoneNumber;
}

눈여겨 볼 부분은 Map<Long,MemberStatus> memberStatus = new HashMap<>(); 입니다.
key, value = { 팀 id, 권한 } 으로 정의가 됩니다.

왜 이렇게 복잡하게 권한을 설정했냐면, 맴버 한명이 A라는 팀에는 일반 팀원일 수도 있고, B라는 팀에는 팀장역할을 수행할 수도 있기 때문입니다. 즉, 자신이 속한 팀에 따라 권한이 다를 수 있기 때문에 Map 자료구조를 사용해서 정의하였습니다.

이를 구현하기 위해서 값 타입 컬렉션 을 사용하였습니다.

MemberStatus

package boomrabbit.logintest.mvc.domain;

/**
 * 사용자(level 1) - 로그인한 사람
 * 팀원(level 2) - 사용자 중에 특정 팀에 가입한 사람
 * 임원(level 3) - 특정 팀의 팀원으로서 팀원 강퇴, 초대권한이 있는 사람
 * 팀장(level 4) - 특정 팀의 팀원으로서 임원의 권한 및 팀삭제 권한을 가진사람
 */
public enum MemberStatus {
    USER, TEAM_MEMBER, MANAGER, LEADER
}

이렇게 4개의 권한이 존재합니다.

loginController

    @PostMapping("/login")
    public String loginV3(@Valid LoginForm loginForm, BindingResult result,
                          HttpServletRequest request, @RequestParam(defaultValue = "/") String redirectURL){
        if(result.hasErrors()){
            return "login/loginForm";
        }
        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        if(loginMember==null){
            result.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        //로그인처리
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
        return "redirect:" + redirectURL;
    }

회원이 로그인을 하였을때 session에 Member Entity를 담아주고 있습니다.

LoginInterceptor

public class LogInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if(!(handler instanceof HandlerMethod)){
            return true;
        }

        HandlerMethod hm = (HandlerMethod) handler;

        Auth auth = hm.getMethodAnnotation(Auth.class);
        //1. @Auth 가 없는 경우는 인증이 별도로 필요없음(비회원도 접근 가능)
        if(auth==null){
            return true;
        }
        //2. @Auth 가 있는 경우에는 세션이 있는지 확인
        String requestURI = request.getRequestURI();
        HttpSession session = request.getSession();

        log.info("인증 체크 인터셉터 실행 {}",requestURI);

        if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
            log.info("미인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        //3. 각 권한 분기처리(팀장,임원,팀원)
        Member member = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
        MemberStatus status = member.getMemberStatus().get(1L);    //임시로 회원가입을 하였을때 팀id 는 1로 고정하였습니다.
        if(auth.role().equals(LEADER)){
            log.info("@Auth : LEADER");
            if(!LEADER.equals(status)){
                log.info("Your don't access here");
                response.sendRedirect("/notAccess");
                return false;
            }
        }
        else if(auth.role().equals(MANAGER)){
            log.info("@Auth : MANAGER");
            if(!LEADER.equals(status) && !MANAGER.equals(status)){
                log.info("Your don't access here");
                response.sendRedirect("/notAccess");
                return false;
            }
        }
        else if(auth.role().equals(TEAM_MEMBER)){
            log.info("@Auth : TEAM_MEMBER");
            if(!LEADER.equals(status) && !MANAGER.equals(status)&&!TEAM_MEMBER.equals(status)){
                log.info("Your don't access here");
                response.sendRedirect("/notAccess");
                return false;
            }
        }
        //log.info("REQUEST [{}][{}][{}]",uuid,requestURI,handler);
        return true;
    }
}

권한에 따라 접근할 수 있는 페이지를 Interceptor를 사용하여 구현해 주었습니다.

자 그럼 대략적인 구조파악은 끝났으니 본문으로 들어가 보겠습니다.
프로젝트를 실행시키고 난 후 동작한 순서는 이렇습니다.

1.회원가입
2.로그인
3.'권한' 페이지에 접근

그러나 3번에서 에러가 떠버립니다....바로 could not initialize proxy - no Session 라는 에러메세지입니다.

왜 이런 에러가 발생하는 것일까요????

바로 프록시 객체로 채워진 Member Entity를 타입캐스팅 하려고 시도했기 때문입니다.
Member member = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER); 이부분이죠.
현재 HttpSession 에는 트랜잭션 범위를 벗어난 상태이고 영속성 컨텍스트도 제거된 상태입니다. 이런 상태에서 Member Entity를 직접적으로 접근하여 사용하려고 하니 이런 에러가 발생하게 된 것 입니다.

그러면 이런 문제점을 어떻게 해야될까요???

처음에는 맘편하게 memberStatus 를 @ElementCollection(fetch = FetchType.EAGER) 로 설정해주었습니다. 그러면 지연로딩이 아닌 실제 객체들로 채워지게 될테니 말이죠. 그러나 이는 결코 좋은 방법이 아닙니다. 당장은 해결한 것 처럼 느껴질 수 있으나 후에 프로젝트가 더 커지고 복잡해 지면 주체할 수 없는 에러들을 유발할 수 있기 때문입니다.

그래서 다른 방법을 고안했습니다.바로 Entity를 직접 사용하는 것이 아니라 Data Transfer Object(DTO)를 사용하는 것 입니다.

@Data
public class SessionMemberDto {

    private String memberId;

    private Map<Long, MemberStatus> memberStatus = new HashMap<>();
}

이렇게 DTO를 선언해 줘서 이놈을 사용하는 것이죠. 즉, Member에서 값들을 빼와서 새로 만들어준 DTO 에 넣어주어서 이놈을 사용하면 됩니다.

LoginController

    @PostMapping("/login")
    public String loginV3(@Valid LoginForm loginForm, BindingResult result,
                          HttpServletRequest request, @RequestParam(defaultValue = "/") String redirectURL){
        if(result.hasErrors()){
            return "login/loginForm";
        }
        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        if(loginMember==null){
            result.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        //로그인처리
        HttpSession session = request.getSession();
        SessionMemberDto memberDto = transferLoginMember(loginMember);
        session.setAttribute(SessionConst.LOGIN_MEMBER,memberDto);
        return "redirect:" + redirectURL;
    }

    private SessionMemberDto transferLoginMember(Member loginMember) {
        SessionMemberDto memberDto = new SessionMemberDto();
        memberDto.setMemberId(loginMember.getMemberId());
        Map<Long,MemberStatus> statusMap = new HashMap<>();
        for(Map.Entry<Long,MemberStatus> u : loginMember.getMemberStatus().entrySet()){
            Long key = u.getKey();
            MemberStatus value = u.getValue();
            statusMap.put(key,value);
        }
        memberDto.setMemberStatus(statusMap);
        return memberDto;
    }

그럼 자연스럽게 Interceptor 도 SessionMemberDto로 가져와서 사용할 수 있게 되는 것이죠.

LoginInterceptor

SessionMemberDto memberDto = (SessionMemberDto)session.getAttribute(SessionConst.LOGIN_MEMBER);
MemberStatus status = memberDto.getMemberStatus().get(1L);

오늘의 결론 : 절대로 데이터를 주고 받을때 엔티티(Entity)를 직접적으로 사용하지 말아라!!!! 후에 Entity 속성이 변경이 되었을때 구현해놓은 api들이 있다면 api 스택들이 바뀌어 버리는 아주 큰 문제점이 발생할 수 있다. 그러니 무조건 DTO를 만들어서 이놈을 사용하자!!!

끝!