Search
Duplicate
🔫

블랙홀에 빠진유저 구제하기

분야
BE
ISSUE
주제
BE
Spring
심각도
높음🔥
제보자
담당자
작성자
상태
처리 완료
이슈링크(optional)
작성일자
2023/08/21 16:38
공개여부
공개
글감

블랙홀

42 서울에는 블랙홀 제도가 있다.
과제를 하나씩 깰 때 마다 블랙홀 날짜를 주며, 수료까지 완료해야하는 과제들을 다 클리어하면, 블랙홀이 사라지게되고, 그전에 블랙홀 날짜가 0일이 되면 퇴학이다.
우리 CABI 서버는 42Intra와 직접적으로 연결된 서버가 아니다. 즉 가입할때와, 블랙홀 체크할 때만 Intra 와 연결하여 API 를 통해 정보를 받아온다.
따라서 사물함을 빌리려는 유저가 퇴학처리된 유저라면, 막아야한다.

대여 로직

전체 흐름도

블랙홀에 빠진 유저에 대한 처리

기존에는 BlackholeManager 가 있다.
BlackholeManager가 하는일은 다음과 같다
@Schedule → crontab 스케쥴러
스케쥴링 주기
1주마다 스케쥴링 : 블랙홀에 가는 날짜가 스케쥴링 도는 시간의 날짜(현재사간보다) 이전인 유저
한달마다 : 모든 유저 조회
블랙홀 유저가 과제를 통과하여, 블랙홀 날짜를 더 얻었는지 intra API (서드파티)요청으로 확인한다.
과제를 통과하여 블랙홀을 더 얻었을경우 → 유저의 블랙홀 날짜(잔여기간)를 갱신하여, DB에 저장한다
과제를 통과하지 못하여 블랙홀에 빠진게 맞는경우
→ 대여한 사물함이 있는지 확인 후 반납처리
→ cabi 계정을 삭제처리

문제점

블랙홀에 빠진 유저가 아닌데, 블랙홀이라고 대여를 못하는 이슈 발생

WHY

블랙홀로 간주된 유저(1주일 혹은 스케쥴러가 체크하는시점에 블랙홀이 없었던 유저)가, 스케쥴러가 돌아서, 블랙홀 갱신여부를 확인한 이후에 과제를 더 밀어서 블랙홀을 얻은경우.
일주일에 한번만 돌기 때문에 다음 스케쥴러가 돌기 전까지, 위와같이 블랙홀에 빠진것으로 간주된다.
대여 프로세스에서 → user 의 blackholedAt 과 현재시간을 비교 ⇒ 아직 갱신 전(스케쥴링 전)이면 일주일 동안은, 블랙홀상태로 보일 수 있다.

그래서..

이미 두번이나 리포팅 되었고 급하게 DB 데이터를 직접 수정하여 해결하였었다.
따라서 최대한 빨리 이 버그를 해결하고자 기존 구조를 크게 손대지 않고 해결하고자 했다.

해결책 Mk.1

1.
스케쥴러를 더 자주 돌린다
이 해결책은 서버의 부하를 늘릴 뿐 아니라, 촘촘하게 돌려서 하루에 한번 한다고 하더라도 위와 같은 현상을 100% 막을 수 는 없다. 12시에 스케쥴러 돌렸는데, 1시에 과제 통과하고 2시에 대여시도하면 위와 똑같은 상황을 맞는다
2.
대여 프로세스에서 블랙홀 유저일때, 한번 더 검사한다.
이게 상황상 맞다고 생각하여 이대로 구현하였다.

기존 로직

전체
public class LentServiceImpl implements LentService { public void startLentCabinet(Long userId, Long cabinetId) { log.info("Called startLentCabinet: {}, {}", userId, cabinetId); LocalDateTime now = LocalDateTime.now(); Cabinet cabinet = cabinetOptionalFetcher.getCabinetForUpdate(cabinetId); User user = userExceptionHandler.getUser(userId); int userActiveLentCount = lentRepository.countUserActiveLent(userId); List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId, now); // 대여 가능한 유저인지 확인 lentOptionalFetcher.handlePolicyStatus( lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList));
Java
복사
LentPolicy.verifyUserForLent(user, cabinet…생략) 정보를 넘겨서 유저에 대한 유효성을 검사한다.
밴된 유저인지
블랙홀에 간 유저인지
이미 빌리고 있는 사물함이 있는지
public class LentPolicyImpl implements LentPolicy { @Override public LentPolicyStatus verifyUserForLent(User user, Cabinet cabinet, int userActiveLentCount, List<BanHistory> userActiveBanList) { log.debug("Called verifyUserForLent"); if (!user.isUserRole(UserRole.USER)) { return LentPolicyStatus.NOT_USER; } if (userActiveLentCount >= 1) { return LentPolicyStatus.ALREADY_LENT_USER; } if (user.getBlackholedAt() != null && user.getBlackholedAt() .isBefore(LocalDateTime.now())) { return LentPolicyStatus.BLACKHOLED_USER; }
Java
복사
여기서, user.getBlackholedAt() 을 가져와 현재시간과 비교해본다. 지금보다 전이면 블랙홀이라는 얘기다.
블랙홀에 빠졌으면 LentPolicyStatus 를 블랙홀에 빠진상태라고 리턴해준다.
이 리턴값을 받아서 바로 블랙홀 유저라고 알려주고, Exception → BadRequest 를 던진다.

LentPolicy 변경

public class LentPolicyImpl implements LentPolicy { private final CabinetProperties cabinetProperties; private final BlackholeManager blackholeManager; //...생략 ... @Override public LentPolicyStatus verifyUserForLent(User user, Cabinet cabinet, int userActiveLentCount, List<BanHistory> userActiveBanList) { // ...생략... if (user.getBlackholedAt() != null && user.getBlackholedAt() .isBefore(LocalDateTime.now())) { blackholeManager.handleBlackHole(User -> UserBlackholeInfoDto) if( 블랙홀 유저라면 ) return LentPolicyStatus.BLACKHOLED_USER; else // 블랙홀 유저가 아니라면, PASS // ...생략... }
Java
복사
블랙홀이라고 간주된 유저의 정보를 넘겨, intra API 로부터 블랙홀 재확인 시킨 후, 처리를한다.
<그러나>
<스파이더맨 짤>
메세지
순환 참조가 된다.

순환참조 ?

BlackholeManager.class
@Component @RequiredArgsConstructor @Log4j2 public class BlackholeManager { private final FtApiManager ftAPIManager; private final LentService lentService; private final UserService userService; //..생략 public void handleBlackhole(UserBlackholeInfoDto userBlackholeInfoDto) { log.info("called handleBlackhole {}", userBlackholeInfoDto); LocalDateTime now = LocalDateTime.now(); try { // Intra API 에서 갱신된 blackhole 정보 가져오기 JsonNode jsonUserInfo = ftAPIManager.getFtUsersInfoByName( userBlackholeInfoDto.getName()); if (!isValidCadet(jsonUserInfo)) { handleNotCadet(userBlackholeInfoDto, now); return; } LocalDateTime newBlackholedAt = parseBlackholedAt(jsonUserInfo); log.info("갱신된 블랙홀 날짜 {}", newBlackholedAt); log.info("오늘 날짜 {}", now); //블랙홀 갔다면 -> 아이디 삭제처리, 대여반납 처리 if (isBlackholed(newBlackholedAt, now)) { handleBlackholed(userBlackholeInfoDto, now); } else { //블랙홀 가지 않았다면, user 의 blackholedAt 갱신 handleNotBlackholed(userBlackholeInfoDto, newBlackholedAt); } catch(){ //... 에러 처리 생락 ... } } //...생략... }
Java
복사
Blackhole 유저에 대한 처리를 할때 참조하는 Service 가 겹친다.
블랙홀에 빠진 유저일때 UserService 로 계정삭제처리, LentService로 대여된 사물함 반납
블랙홀 갱신된 유저일때 UserService 로 user 블랙홀 날짜 업데이트
public class LentServiceImpl implements LentService { private final LentRepository lentRepository; private final LentPolicy lentPolicy; //... } public class LentPolicyImpl implements LentPolicy { private final CabinetProperties cabinetProperties; private final BlackholeManager blackholeManager; //... } public class BlackholeManager { private final FtApiManager ftAPIManager; private final LentService lentService; private final UserService userService; // ... }
Java
복사
LentServiceLentPolicyBlackholeManager 로 블랙홀로 간주된 유저 블랙홀 확인 및 처리
BlackholeManager→LentService 블랙홀에 빠진 유저일때, LentService 로 대여된 사물함 반납
⇒ 순환참조 이슈 발생

해결책 Mk.2

public class BlackholeRefresher { private final FtApiManager ftApiManager; private final UserService userService; public JsonNode getBlackholeInfo(String userName) throws ServiceException, HttpClientErrorException { log.info("called refreshBlackhole{}", userName); return ftApiManager.getFtUsersInfoByName( userName); }
Java
복사
public class BlackholeRefresher { private final FtApiManager ftApiManager; private final UserService userService; /** * 유저의 블랙홀 정보를 찾아온다. **/ public JsonNode getBlackholeInfo(String userName) throws ServiceException, HttpClientErrorException { log.info("called refreshBlackhole{}", userName); return ftApiManager.getFtUsersInfoByName( userName); } /** * 갱신된 블랙홀 날짜를 바탕으로 블랙홀에 빠졌는지 확인한다. * * @return 블랙홀에 빠졌는지 여부 */ public Boolean isBlackholedAndUpdateBlackhole(UserBlackholeInfoDto userBlackholeInfoDto) { log.info("isBlackholedAndUpdateBlackhole {}", userBlackholeInfoDto); LocalDateTime now = LocalDateTime.now(); JsonNode blackholeInfo = getBlackholeInfo(userBlackholeInfoDto.getName()); LocalDateTime blackholedAtDate = parseBlackholedAt(blackholeInfo); if (blackholedAtDate == null || blackholedAtDate.isAfter(now)) { userService.updateUserBlackholedAt(userBlackholeInfoDto.getUserId(), blackholedAtDate); return false; } else { return true; } }
Java
복사
public class BlackholeManager { // private final FtApiManager ftAPIManager; private final LentService lentService; private final UserService userService; private final BlackholeRefresher blackholeRefresher; public void handleBlackhole(UserBlackholeInfoDto userInfoDto) { log.info("called handleBlackhole {}", userInfoDto); LocalDateTime now = LocalDateTime.now(); try { // // JsonNode jsonUserInfo = ftAPIManager.getFtUsersInfoByName(userBlackholeInfoDto.getName()); JsonNode jsonRefreshedUserInfo = blackholeRefresher.getBlackholeInfo(userInfoDto.getName()); if (!isValidCadet(jsonRefreshedUserInfo)) { handleNotCadet(userInfoDto, now); return; } LocalDateTime newBlackholedAt = parseBlackholedAt(jsonRefreshedUserInfo); log.info("갱신된 블랙홀 날짜 {}", newBlackholedAt); log.info("오늘 날짜 {}", now);
Java
복사
intra API 에서, 갱신로직 분리
BlackholeManger 는 블랙홀 유저/대여/반납 등 통합 처리 (스케쥴러가 처리하는 내용)
BlackholeRefresher 는 유저에 대한 블랙홀 “갱신” 처리 로 분리
public class LentPolicyImpl implements LentPolicy { private final CabinetProperties cabinetProperties; private final BlackholeRefresher blackholeRefresher; //... @Override public LentPolicyStatus verifyUserForLent(User user, Cabinet cabinet, int userActiveLentCount, List<BanHistory> userActiveBanList) { log.debug("Called verifyUserForLent"); if (!user.isUserRole(UserRole.USER)) { return LentPolicyStatus.NOT_USER; } if (userActiveLentCount >= 1) { return LentPolicyStatus.ALREADY_LENT_USER; } // 유저의 블랙홀 업데이트와 블랙홀 체크 분리필요- 리펙토링 필요 2023.08.15 if (user.getBlackholedAt() != null && user.getBlackholedAt() .isBefore(LocalDateTime.now())) { if(blackholeRefresher.isBlackholedAndUpdateBlackhole(UserBlackholeInfoDto.of(user))) return LentPolicyStatus.BLACKHOLED_USER; } //... return ret; }
Java
복사
LentService→LentPolicy 에서는 BlackholeManager 가 아닌, BlackHoleRefresher 를 포함하도록 변경 ⇒ 순환참조 해결
블랙홀에 빠졌다고해서, 전체 처리를 할게 아닌, 유저 블랙홀 업데이트 정도만 진행하도록 한다.

이벤트?

순환 참조를 해결하기 위해서, 이벤트를 받고 처리하는 방식이 존재했다.
스프링 이벤트란?
위와 같은 포함 / 사용 구조, 의존성을 주입 받아야하는 구조에서 순환참조가 발생할때, 이벤트를 발생시키고, 그 이벤트를 리스닝 하는 리스너로 처리하여, 순환 구조의 고리를 깨는 방식이다.
자세한 내용은, sanan님의 스프링에서 이벤트 구현하기 글 참조

해결책_real_final

구현

아래 그림과 같은 구조로 개선한다.
@Component @RequiredArgsConstructor @Log4j2 public class LentPolicyImpl implements LentPolicy { private final CabinetProperties cabinetProperties; private final ApplicationEventPublisher publisher; .. 생략 .. @Override public LentPolicyStatus verifyUserForLent(User user, Cabinet cabinet, int userActiveLentCount, List<BanHistory> userActiveBanList) { log.debug("Called verifyUserForLent"); if (!user.isUserRole(UserRole.USER)) { return LentPolicyStatus.NOT_USER; } if (userActiveLentCount >= 1) { return LentPolicyStatus.ALREADY_LENT_USER; } if (user.getBlackholedAt() != null && user.getBlackholedAt() .isBefore(LocalDateTime.now())) { publisher.publishEvent(UserBlackholeInfoDto.of(user)); if (user.getBlackholedAt() != null && user.getBlackholedAt() .isBefore(LocalDateTime.now())) { return LentPolicyStatus.BLACKHOLED_USER; } }
Java
복사
LentPolicy 에서, 대여를 신청한 유저가 블랙홀 상태일때, 바로 상태를 바꾸는게 아니라, 한번 확인 절차를 거친다.
publishEvent 를 실행하면 아래의 @EventListener 가 달린 함수를 수행한다.
handleBlackholedUserLentAttemptingEvent 가 수행된다
@Log4j2 @Component @RequiredArgsConstructor public class BlackholedUserLentEventListener { private final BlackholeManager blackholeManager; @EventListener public void handleBlackholedUserLentAttemptingEvent(UserBlackholeInfoDto userBlackholeInfoDto) { log.info("Called handleBlackholedUserLentAttemptingEvent"); blackholeManager.handleBlackhole(userBlackholeInfoDto); } }
Java
복사
blackholeManager 는 기존의 로직을 수행한다.

결론

⇒ 위와같이 순환참조는 간단하게 이벤트로 해결할 수 있다.