블랙홀
•
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
복사
•
LentService → LentPolicy → BlackholeManager 로 블랙홀로 간주된 유저 블랙홀 확인 및 처리
•
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 를 포함하도록 변경 ⇒ 순환참조 해결
◦
블랙홀에 빠졌다고해서, 전체 처리를 할게 아닌, 유저 블랙홀 업데이트 정도만 진행하도록 한다.
이벤트?
•
순환 참조를 해결하기 위해서, 이벤트를 받고 처리하는 방식이 존재했다.
스프링 이벤트란?
•
위와 같은 포함 / 사용 구조, 의존성을 주입 받아야하는 구조에서 순환참조가 발생할때,
이벤트를 발생시키고, 그 이벤트를 리스닝 하는 리스너로 처리하여, 순환 구조의 고리를 깨는 방식이다.
해결책_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 는 기존의 로직을 수행한다.
결론
⇒ 위와같이 순환참조는 간단하게 이벤트로 해결할 수 있다.