MarbleUs Project #4: JPA를 사용하는 Entity: Member

John Jun·2023년 10월 20일
0

table of contents


1. Member 앤티티: 회원관리 CRUD
2. Member 앤티티 관련 문제파악 및 개선사항
3. 회고

1. Member 앤티티: 회원관리 CRUD

회원가입을 포함한 관리를 위한 기본 CRUD기능을 구현하였다. 우리 서비스의 게임적인 기능의 특성상 맴버 앤티티에 담기는 내용이 비교적 많고 복잡도도 비교적 높았다. 필수적으로 담겨야 하는 내용으로는 1. email, 2. password, 3. roles, 4. nickname, 5. birth, 6.nationality가 있었고

특이사항으로는 1. 유저가 보드판을 한바퀴 돌때마다 1씩 올라가는 게임 플레이를 위한 level, 2. 유저의 현재 위치를 저장하는 currentLocation Collection 콜럼, 3. 방문 도시 기록을 위한 visitedCities Collection 콜럼, 4. 유저가 배정받은 미션을 저장하기 위한 MemberMission객체를 저장할 myMissions, 5. 유저가 미션을 달성했을때 리워드로서 발급할 Stamp객체를 저장하는 myStamps, 6. 해당 유저가 쓴 블로그 객체를 저장하기 위한 myBlogs, 7. 유저가 쓴 Comment(댓글)를 저장할 myComments, 8. 타 유저들의 블로그를 저장하기 위한 bookmarks 9.팔로잉과 팔로워(맴버간의 조인테이블)를 저장하는 follows와 followers 가 있다.

이를 구현하는데 많은 고민점들과 난관들이 있었지만 그 첫번째는 유저의 위치를 어떻게 저장할 것인가였다. 많은 아이디어를 가지고 많은 고민을 하였다. 그리고 채택한 방법은 부루마블 보드에 위치하는 도시의 이름과 위치정보를 담은 ENUM을 만들어 유저의 위치 정보를 도시앤티티와 분리하여 최대한 가볍게 기록 조회하도록 하는 방법이었다. 하지만 이때 한가지 크게 대두되는 문제가 있었다. 블록의 위치 정보와 해당 하는 도시를 고정적으로 만들면 변경사항들에 있어서 유연하지 못하다는 점이였다. 자세히 설명하자면 가령 도시의 순서를 바꿀때 그 위치 ENUM 전체를 고쳐야 한다는 큰 제약사항이 있었다.

이런 불편 사항을 해소시키기 위해 나는 도시와 블록의 위치를 완전히 분리 시키기로 하였다. 부루마블 블록의 정보를 ENUM으로 관리하되 해당 칸에 위치하는 도시의 정보는 공백으로 두고, 유저가 부루마블 판의 해당하는 칸에 도착할때 클라이언트는 도시 앤티티의 name과 일치하는 도시의 이름를 해당 member 앤티티에 대한 PATCH 요청을 통해 동적으로 블록과 해당 블록의 도시이름을 매핑하여 유저의 currentCity 및 visitedCities 콜럼에 저장한다. 이를 통해 최소한의 코드의 수정으로 도시의 블록 위치를 유연하게 수정할 수 있게 되었다.

아래는 실제적으로 어떻게 이를 적용하였는지에 대한 코드이다.

public enum UserLocations {

    BLOCK_0(0, "시작점"),
    BLOCK_A(1, ""),
    BLOCK_B(2, ""),
    BLOCK_C(3, ""),
    BLOCK_D(4, ""),
    BLOCK_E(5, ""),
    BLOCK_F(6, ""),
    BLOCK_G(7, ""),
    BLOCK_H(8, ""),
    BLOCK_I(9, ""),
    BLOCK_J(10, ""),
    BLOCK_K(11, ""),
    BLOCK_L(12, ""),
    BLOCK_M(13, ""),
    BLOCK_N(14, ""),
    BLOCK_O(15, ""),
    BLOCK_P(16, ""),
    BLOCK_Q(17, ""),
    BLOCK_R(18, ""),
    BLOCK_S(19, "");

    @Getter
    private final int num;

    @Getter
    @Setter
    private String cityName;

    UserLocations(int num, String cityName) {
        this.num = num;
        this.cityName = cityName;
    }
}

이처럼 유저가 해당 블록의 위치에 도착을 하고 해당하는 블록위의 도시의 이름을 PATCH 요청을 통해 서버에 보내게 되면 공백으로 관리되고 있는 cityName을 매퍼를 통해 실제 앤티티로 변환시에 매핑시켜 저장한다.

아래는 이를 실현하고 있는 매퍼의 메소드이다.

default Member patchToMember(MemberDto.Patch patch){
        if ( patch == null ) {
            return null;
        }

        Member member = new Member();
        Blog blog = new Blog();
        blog.setId(patch.getBookmarkId());

        member.setNickname( patch.getNickname() );
        member.setPassword( patch.getPassword() );
        member.addBookMarks(blog);

        UserLocations currentLocation = patch.getCurrentLocation();
        if (currentLocation != null) {
            currentLocation.setCityName(patch.getCurrentCityCode()); // dto를 통해 받아온 도시이름을 ENUM에 매핑시켜 저장하는 코드이다.
            member.setCurrentLocation(currentLocation);
        }
        member.setNationality( patch.getNationality() );

        return member;
    }

두번째 난관은 북마크에 관한 부분이였다. 원래의 계획은 앤티티간의 매핑을 통해 클라이언트의 요청에 따라 실제 blog객체를 찾아 저장하는 식으로 이를 구현하였다. 물론 큰 문제없이 잘 동작하였다. 하지만, 프로그램의 성능과 실제로 북마크를 사용하는 곳인 마이페이지에서 북마크한 블로그들을 로딩할때 맴버와 분리된 블로그의 아이디를 이용하는 API를 이용하는것이 페이지네이션을 비롯한 효율적이고 이를 위해 저장되어야할 정보는 사실 해당 블로그들의 아이디뿐이였다. 또한, 무엇보다 북마크를 위한 유저의 액션인 굉장히 순간적인것으로 그 속도를 빠르게 할 필요가 있다는 고민을 하게 되었다. 복잡한 관계는 쿼리의 속도를 저하시키는것 자명한 사실이였다. 더해서, 팔로우와 팔로워와는 다르게 북마크는 양방향으로 매핑이 되어야 할 필요도 없다는 점 또한 연관관계 매핑을 사용하지 않는 결정을 내리는데 큰 이유가 되었다. 그래서 나는 관계를 매핑하여 북마크정보를 저장하기 보다는 아이디만을 ElementCollection 콜럼으로 저장하기로 수정하였다.

    ...
    //해당 사용자가 작성한 블로그 객체가 저장된다.
    
    @OneToMany(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<Blog> myBlogs = new ArrayList<>();
    
    //해당 사용자가 북마크한 블로그의 아이디만 저장된다.

    @ElementCollection
    private List<Long> bookmarks = new ArrayList<>();
    
    ...
    
    //북마크를 추가하기 위한 member Entity의 메소드이다.
    
        public void addBookMarks(Blog blog) {
        if (myBlogs.contains(blog)) throw new BusinessLogicException(ExceptionCode.NOT_ALLOWED_BOOKMARK); //해당 사용자 본인이 작성한 블로그는북마크할 수 없다.
        if (bookmarks.contains(blog.getId())) throw new BusinessLogicException(ExceptionCode.ALREADY_BOOKMARKED);
        //이미 추가되어 있는 블로그는 추가할 수 없다.
        bookmarks.add(blog.getId());
    }
    public void deleteBookmark(Long id) {

        if (!bookmarks.contains(id)) throw new BusinessLogicException(ExceptionCode.BLOG_NOT_FOUND);
        bookmarks.removeIf(blogId -> blogId.equals(id));
    }
    
    ...
    

다음은 마이페이지에서 사용자의 북마크 목록을 추가/조회/삭제하기 위한 맴버의 서비스 코드이다.

public Page<Blog> findBookMarks(Member findMember,Long loginMember,int page,int size) {

        verifyIsSameMember(findMember,loginMember);

        PageRequest pageRequest = PageRequest.of(page-1, size, Sort.by("createdAt").descending());

        List<Long> myBookmarks = findMember.getBookmarks();
        //연관관계 매핑을 사용하지 않기때문에 조회시마다 실제 블로그가 존재하는지를 검증하여 지워진 블로그라면 이를 맴버의 북마크 콜럼에서 제거해주어야 했다.
        List<Blog> bookMarks = findMember.getBookmarks().stream().map(id->{
            Optional<Blog> findBlog = blogRepository.findById(id);
            if (findBlog.isEmpty()) {
                myBookmarks.remove(id);
            }
            return findBlog.orElse(null);
        }).collect(Collectors.toList());
        List<Blog> result = bookMarks.stream().filter(Objects::nonNull).collect(Collectors.toList());
        //
        return new PageImpl<>(result,pageRequest,bookMarks.size());
    }

    public void addBookMark(Long memberId, Long loginMember ,Long blogId) {

        Member findMember = findVerifiedMember(memberId);
        verifyIsSameMember(findMember,loginMember);
        findMember.addBookMarks(blogRepository.findById(blogId).orElseThrow(()-> new BusinessLogicException(ExceptionCode.BLOG_NOT_FOUND)));
        saveMember(findMember);
    }

    public void deleteBookMark(Long memberId,Long loginMember, Long blogId) {
        Member findMember = findVerifiedMember(memberId);
        verifyIsSameMember(findMember,loginMember);
        findMember.deleteBookmark(blogId);
        saveMember(findMember);
    }

마지막으로 팔로우와 팔로워를 구현하기위해 맴버간의 조인테이블을 만들어 이를 저장하는 형태로 이를 구현하였다. 아래는 이를 구현한 코드이다.

@Entity
@Getter
@Setter
public class Follow extends Auditable {
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "followed_id")
    private Member followedMember;

}
@Entity
@Getter
@Setter
public class Follower extends BaseEntity {
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "follower_id")
    private Member follower;
}

또한 아래는 이를 사용하는 맴버의 서비스 레이어 코드이다.

 public void saveFollowing(Member findMember, Member followedMember, Long loginMember) {

        verifyIsSameMember(findMember,loginMember);

		//사용자가 누군가를 팔로잉할때 팔로우와 팔로워 인스턴스 만들어 서로의 follow 와 follower 콜럼에 저장한다. 쿼리의 커스텀을 통해 팔로우와 팔로워를 분리하지 않고서도 서로를 조회할 수 있지만, 조회시 맴버 앤티티에 저장되어 있는 정보만으로 추가적이 쿼리없이 빠르게 조회할 수 있도록 하기 위해 이를 분리하여 저장하는 방식으로 구현하였다. 이를 통해 JPA의 최대 장점중 하나인 양방향 연관관계 매핑을 통한 cascade정책을 사용할 수 있고 동시에 쿼리의 복잡도를 낮춤과 동시에 조금더 명시적인 코드와 관계를 정의할 수 있었다. 
        if(memberVerifier.verifyIsMemberActive(followedMember)){

            Follow follow = new Follow();
            follow.setMember(findMember);
            follow.setFollowedMember(followedMember);
            followRepository.save(follow);
            findMember.addFollow(follow);
            saveMember(findMember);

            Follower follower = new Follower();
            follower.setMember(followedMember);
            follower.setFollower(findMember);
            followerRepository.save(follower);
            followedMember.addFollower(follower);
            saveMember(followedMember);}
        else throw new BusinessLogicException(ExceptionCode.MEMBER_INACTIVE);
    }

    public void unfollowMember(Member findMember, Member followedMember, Long loginMember) {

        verifyIsSameMember(findMember,loginMember);

        Follow follow = followRepository.findByMemberAndFollowedMember(findMember,followedMember).get();
        findMember.unFollow(follow);
        followRepository.delete(follow);
        saveMember(findMember);

        Follower follower = followerRepository.findByMemberAndFollower(followedMember,findMember).get();
        followedMember.deleteFollower(follower);
        followerRepository.delete(follower);
        saveMember(followedMember);

    }

    public Page<Member> findFollows(Member findMember,Long loginMember,int page,int size) {

        verifyIsSameMember(findMember,loginMember);
        
        //팔로우 조회시 Follow 객체로 저장되어 있는 follows의 정보들에서 Member정보를 찾아 스트림을 통해 변환 시켜준다. 이때, 맴버의 상태가 Inactive(비활성중)이라면 null값을 리턴하고 필터를 통해 널값을 제거한다.

        List<Member> follows = findMember.getFollows().stream().map(
                follow-> {
                    Member followedMember = follow.getFollowedMember();
                    if (memberVerifier.verifyIsMemberActive(followedMember)) {return followedMember;
                    }else return null;
                }).filter(Objects::nonNull).collect(Collectors.toList());
        PageRequest pageRequest = PageRequest.of(page-1, size, Sort.by("createdAt").descending());

        return new PageImpl<>(follows,pageRequest,follows.size());
    }

    public Page<Member> findFollowers(Member findMember,Long loginMember, int page,int size) {

        verifyIsSameMember(findMember,loginMember);

        List<Member> followers = findMember.getFollowers().stream().map(
                follower-> {
                    Member findFollower = follower.getFollower();
                    if (memberVerifier.verifyIsMemberActive(findFollower)) {return findFollower;}
                    else return null;
                }).filter(Objects::nonNull).collect(Collectors.toList());
        PageRequest pageRequest = PageRequest.of(page-1, size, Sort.by("createdAt").descending());

        return new PageImpl<>(followers,pageRequest,followers.size());
    }

1-1. Member 앤티티 관련 문제파악 및 개선사항

사실 문제점이라기 보다는 서비스 운영관련 개선점이라고 하는 것이 맞을것이다. 나는 서비스의 운영면에서도 고민을 많이하고 그 불편을 최소화하기 위한 노력을 하는 것 또한 서버 개발자의 중요한 덕목이라 생각한다. 따라서 어떻게 하면 맴버 관리를 최적화하고 운영적으로 원할하게 개선할 수 있을까를 놓고 기능 구현 후에 많이 고민하였다.

첫번째 개선점

첫번째 개선점은 dto의 용도에 따른 분리였다. 맴버 정보를 담아 클라이언트에게 전달하는 Response Dto는 그 용도와 사용 위치에 따라 최적화된 정보만을 전달할 수 있도록 담는 정보의 가짓수를 차별화여 분리할 필요가 있었다. 가령 블로그나 댓글등의 작성자의 정보를 클라이언트에게 전달할때 기존의 맴버 리스폰스 DTO를 사용하면 쓸데없는 정보들이 너무 많이 담긴다는것이 문제였다. 리스폰스 바디의 내용을 최대한 줄이는것은 쿼리의 속도를 개선하는데 고려되어야 할 첫번째 사항이기에 이는 성능면에서 굉장히 중요한 부분이였다. 따라서 나는 아래와 같이 Response DTO를 분리하였다.

기존의 맴버 리스폰스


 @Getter
    @Setter
    public static class Response{

        private Long id;
        private String nickname;
        private String email;
        private int level;
        private List<String> roles;
        private List<ImageResponseDto> profilePics;
        private String nationality;
        private LocalDate birth;
        private int follows;
        private int followers;
        private List<Stamps> myStamps;
        private UserLocations currentLocation;
        private List<UserLocations> visitedCities;

        private List<Long> bookmarks;

        private LocalDateTime createdAt;
        private LocalDateTime modifiedAt;
    }

추가된 맴버 리스폰스, MemberSummarizedResponse

@Getter
@Setter
public class MemberSummarizedResponse {
    private Long id;
    private String nickname;
    private String profile;

}

이를 통해 블로그/커맨트 등의 리스폰스에 담을 맴버정보를 필요한 정보만을 담아 전송할 수 있게 되었다.

두번째 개선점

두번째 개선점은 운영적인 측면이였다. 사용자의 마음은 갈대이기에 언제든 탈퇴를 하고 또 탈퇴 이후에도 빠르게 계정을 복구하는 면으로 유저를 관리하면 편리할것 같다는 생각이 들었다. 이에 유저가 탈퇴를 원할때 곧바로 이를 삭제하기 보다는 그 상태값을 Active와 Inactive로 두고 일정 시간동안 이를 복구할 기회를 주도록 관리하면 어떨까하는 아이디어가 떠올랐고 또한 장시간 사용기록이 없는 사용자들을 판별하고 이를 삭제해주는 로직 또한 편의성과 서버의 리소스 관리를 위해 필요하다는 생각이 들어 이를 위한 연관된 필수 기능들을 고민하였다. 이를 위한 기능들은 다음과 같다. 1. 맴버 탈퇴시 맴버 상태값을 변경하는 메소드, 2. 맴버가 오랜기간 로그인이 기록이 없는 경우 이를 잠재적 탈퇴한 사용자로 보고 상태값을 inactive로 변경한다. 3. 탈퇴/휴면 맴버가 돌아오지 않을경우 이를 실제로 삭제하는 로직, 4. 맴버를 조회할때 또 로그인시 맴버의 상태값이 active인지 검증하는 로직

이를위해 먼저 Member Entity에 그 상태값을 가진 Enum 클래스를 만들어 주었다.

...

    @Enumerated(EnumType.STRING)//이넘 타입을 String으로 설정하여 출력되는 값이 문자열이 나올 수 있도록 설정한다.
    private Status memberStatus = Status.MEMBER_ACTIVE;
    //맴버가 태어날때 기본 상태값을 ACTIVE로 생성한다.

...
 public enum Status{

        MEMBER_ACTIVE("member is active"),

        MEMBER_INACTIVE("member is deactivated");

        @Getter
        private String description;

        Status(String description) {
            this.description = description;
        }
    }

그런후에 이를 검증하는 코드들을 필요한 메소드들에 추가해 주었다. 블로그와 댓글에서는 이를 검증하지 않고 있는데 그 이유는 작성자가 탈퇴하여도 그 작성물들을 같이 삭제하여야 하는가에 대한 부분은 아직 고민하고 있는 부분이기에 그렇다. 많은 서비스들이 맴버가 탈퇴하더라도 작성물들은 삭제하지 않는 경우들이 꽤 있었고 이또한 다른 사용자들의 편의를 위해 괜찮은 정책인것 같았고 만일 개인정보에 대한 부분이 추후 문제가 된다면 이를 삭제하는 BATCH기능을 추가해 이를 처리할 예정이다.

다음으로 위에서 설명한 2과 3번 기능을 구현하기 위해 Srping Scheduler를 이용한 BATCH 기능을 사용하였다. 그 내용은 다음과 같다. a. 로그인할때 마다 맴버 앤티티에 lastLogin 콜럼을 두고 그 시간을 기록한다. b. Scheduler를 통해 유저들의 로그인 기록을 조회해 일년동안 그 기록이 없는 맴버의 status를 Inactive로 바꾼다. c. Scheduler를 통해 그 상태값이 Inactive 인지를 확인하고 이를 삭제한다.

아래는 a. 기능 구현을 위한 로그인시에 맴버의 상태를 검증하고 로그인 시간을 기록하는 부분이다..

일반 로그인시

@SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        Member member = memberService.findMemberByEmail(loginDto.getEmail());
        //로그인 시도시 맴버의 상태값이 ACTIVE인지 검증
        if (member.getMemberStatus() == Member.Status.MEMBER_INACTIVE) throw new AuthException("Member is inactive. Please contact us.");

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Member member = (Member) authResult.getPrincipal();  //

        String accessToken = delegateAccessToken(member);   //
        String ip = extractor.getClientIP(request);
        delegateRefreshToken(member,ip); //
        log.info("accessToken is generated");


        //로그인 히스토리 생성
        Member findMember = memberService.findVerifiedMember(member.getId());
        findMember.setLastLogin(LocalDateTime.now());
        memberService.saveMember(findMember);


        response.setHeader("Authorization", accessToken);

    }

오어스 로그인시 이메일을 이용해 이미 저장된 맴버인지 일차 검증 후 이미 가입된 맴버라면(DB에 존재하면) 저장하지 않고 최초 가입시에는 맴버정보를 DB에 저장한다. 그리고 해당 맴버의 상태값의 유효성을 검증하고 Active 상태라면 로그인 시간을 기록하고 Inactive 라면 ExceptionCode를 전송한다.

...
@Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        var oAuth2User = (OAuth2User) authentication.getPrincipal();

        String email = String.valueOf(oAuth2User.getAttributes().get("email"));
        List<String> authorities = authorityUtils.createRoles(email);
        if (!memberVerifier.verifyExistMember(email)) {saveMember(email, authorities);}


        //로그인 히스토리 생성
        Member findMember = memberService.findMemberByEmail(email);
        if (memberVerifier.verifyIsMemberActive(findMember)){
        findMember.setLastLogin(LocalDateTime.now());
        memberService.saveMember(findMember);
        } else {throw new BusinessLogicException(ExceptionCode.MEMBER_INACTIVE);
        }

        redirect(request,response,email,authorities);
    }
    ...

또한 b., c. 기능 구현을 위한 BATCH기능의 일환으로 Spring Scheduler를 사용하여 상태값과 맴버를 일괄 변경 삭제하는 부분이다.

아래의 코드는 매일 자정에 실행되며 마지막 로그인 날짜와 현재의 날짜를 비교하여 일년이 지났다면 맴버의 상태를 Inactive로 바꾸어 주는 역할을 한다.

    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")// Run every day
//    @Scheduled(cron ="0 * * * * *")
    private void updateMemberStatus() {
        LocalDateTime thresholdDate = LocalDateTime.now().minus(1, ChronoUnit.YEARS); //inactive member if last login date is passed more than 1 year
//       LocalDateTime thresholdDate = LocalDateTime.now().minus(1,ChronoUnit.MINUTES);
//        if (lastLogin.isBefore(thresholdDate)){

        List<Member> members = repository.findAllByLastLogin(thresholdDate);
        members.stream().map(m ->{
            m.setMemberStatus(Member.Status.MEMBER_INACTIVE);
            repository.save(m);
            return m;
        }).collect(Collectors.toList());

//        }
    }
}

아래의 코드 또한 매일 자정에 실행되며 Inactive 상태의 맴버를 일괄 삭제한다.

@Component
@RequiredArgsConstructor
public class MemberCleanupTask {

    private final MemberRepository memberRepository;


    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") //every day
//    @Scheduled(cron = "0 */2 * * * *")
    private void deleteInactiveUsers() {
        List<Member> membersToDelete = memberRepository.findAllByMemberStatus(Member.Status.MEMBER_INACTIVE);
        memberRepository.deleteAll(membersToDelete);
    }
}

문제발생!

처음에는 단순히 로그인 기록의 유효기간이 일녕이니 일년에 한번 이를 검증해서 상태를 바꾸고 오년에 한번 일괄되게 이를 삭제하면 된다고 생각하였고 그렇게 설정을 하였다. 하지만 그렇게 할 경우 오차의 범위가 굉장히 커져서 '거의' 일년동안 활동이 없던 맴버가 삭제되지 않고 다시 오년을 기다려 삭제될 수 도 있는 등의 크나큰 오류가 나올 수 있다는 점을 발견하였고 이를 매일 자정에 실행되도록 수정하여 그 오차 범위를 줄이는 식으로 수정하여 해결하였다. 하지만!! 새로운 문제를 조우하였다.

Dang it! 같은 시간에 설정된 task의 우선순위를 보장할 수 없다는것이 그것이였다. 둘다 같은 시간에 실행되게 설정되어있고 우선순위는 분명 맴버의 상태값을 바꾸는것이 우선되어야 하는 task였고 이를 설정하는 방법을 알아보았다. 알아본 방법은 대략 4가지였다.

해결법 1. 하나의 메소드에 순차적으로 두가지 task를 정의하여 실행한다.
해결법 2. fixedDelay를 사용한다. ex) @Scheduled(fixedDelay= "") 앞서 실행된 task보다 명시한 시간만큼 딜레된 시간에 해당 task를 실행한다.
해결법 3. Queue를 만들어 task를 먼저 순차적으로 큐에 넣고 TaskQueue에서 순차적으로 꺼내어 실행한다.
해결법 4. 크론 시간 설정을 애초에 일정 시간 딜레이된 시간으로 설정한다.

이중에 내가 채택한 방법은 4번째였다. 그 이유는 우선 너무 많은 리소스를 사용하고 싶지 않았다는것이 그 이유이고 이 방법이 가장 간단하고 또한 확실한 방법이라는 판단에서였다.

따라서 Inactive 맴버를 일괄 삭제하는 코드를 아래와 같이 수정해 주었다.

    @Scheduled(cron = "0 2 0 * * *", zone = "Asia/Seoul") //every 12:02
    private void deleteInactiveUsers() {
        List<Member> membersToDelete = memberRepository.findAllByMemberStatus(Member.Status.MEMBER_INACTIVE);
        memberRepository.deleteAll(membersToDelete);
    }

위의 작업을 통해 우선 순위를 설정할 수 있었다.

3. 회고: 생각해봐야 할 개선점

유저 관리와 서버스의 핵심 기능들을 사용자가 사용하기 위해선 맴버 앤티티를 필연적으로 다소 복잡한 연관관계 매핑을 할 필요가 있었고 이를 최적화하여 최대한 가볍게 만드는것이 가장큰 숙제였고 아직도 남아있는 숙제라 느껴진다. 내가 구현한 방법들이 정답이라 절대 생각하지 않는다. 다만, 지금 나의 수준에서 많은 고민과 조사 및 공부를 하였고 내가 그 효율성을 판단해볼 수 있는 귀중한 기회들이였고 내 판단 속에서 최선이라 생각되어지는 방법들로 다 구현을 하였기 때문에 상당부분 만족하였다.

리소스에 대해 끊임없이 고민하고 최적화하는 것이 서버 개발에 있어서의 핵심이고 개발자의 숙명이라 생각한다. 이번 작업을 통해 특히, BATCH기능을 구현하면서 느낀 가장 큰 부분은 생각보다 Batch를 위한 Scheduling 기능의 리소스가 크다는 것이다. 회원의 수가 늘어나고 많아 진다면 매일 돌아가는 이 기능이 큰 서비스 장애를 유발할 수 있을거란 우려가 남았다. 이는 이 기능을 통한 득보다 실이 더 커질 수 있다는 것이다. 따라서 서비스가 커지고 유저가 늘어난다면 Batch 작업들만을 실행하는 서버를 따로 분리하여 처리하는 방법으로 해결할 수 있을 것이다.

복잡할수록 재미있고 어려울수록 흥미롭다!

profile
I'm a musician who wants to be a developer.

0개의 댓글