250219
250226
250304

 

약 3주동안 항상 해왔던 소리

엘라스틱 서치와 친해지고 오겠다. . .

를 드디어 실행하는,,

그래도 나름 백엔드 지망생으로서

코드를 쳐보고 싶었으니깐요

기대된다 ! ! ! ! 

 


요구사항

기존 Rest API를 사용하였던 각종 검색 기능을 Elastic Search로 변환하기.

 

예상 적용 범위

  이름 URI MEMO
1 회원 목록 조회 API /api/v1/members/search?keyword={keyword}
  • ‘나의 앨범’ 태그 검색 or 앨범 생성시 사용자 검색 시에 사용
  • 닉네임 검색시, 포함하는 닉네임 목록 반환
2 앨범 검색 API /api/v1/albums/search?keyword={keyword}
  • 사용자가 포함된 앨범을 키워드 검색하여, 최신순 조회
  • 앨범명, 포함된 사용자 검색
3 태그 내 앨범 검색 API /api/v1/albums/tag?keyword={keyword}
  • 태그에서 검색 시 앨범 조회 기능입니다.
  • keyword로 검색이 가능합니다.

 

  • 추가로 담당자와 논의 필요
  • 아마 3번만 바꾸면 될 것 같음

 

예상 Flow

  • Spring Data Elasticsearch 의존성을 주입
  • @ElasticsearchDocument class 생성
    • 앨범 field값: id, 앨범 명, 구성 인원
    • 멤버 field값: id, 이름
  • EntityListeners를 통한 MySQL 업데이트 - 엘라스틱 서치 동기화

 

고려 사항

ElasticsearchDocument 내 저장할 field 값들

후 유지보수를 위해, 최소한의 구별 값들만 저장 후 일반 서비스단에서 returnDto로 변환하는 과정 적용

 

MySQL과 Elasticsearch 동기화 작업: 이중 작성, 이벤트 리스너, 메시지 큐

  • 이중 작성
    • MySQL 및 Elasticsearch 내 crud 작업 코드 추가
    • 일관성 문제 발생 가능
    • 불편함
  • 이벤트 리스너
    • 비교적 간단하게 구현 가능
    • 비동기 처리
  • 메시지 큐(RabbitMQ, Kafka)
    • 어떻게 보면 결합도를 낮추고 응집도를 높이는,, 가장 이상적인,,
    • 후에 플젝 확장성을 본다면 가장 이상적인 선택
    • 서버 남은 공간이 많지 않음
    • 대공사(살짝 과할수도)
  • 나머지(배치, CDC)
    • 프로젝트나 팀에 상황과 맞지 않음

그래서 JPA 이벤트 리스너를 사용하고자 합니다..

https://photorize.co.kr

 

Photorize

 

photorize.co.kr

 

현재 뽀또라이즈를 Gitlab -> Github, Mattermost -> Discord로 옮기는 작업을 하고 있어요.

원래 프로젝트CI/CD를 Jenkins에서 관리했단 말이죠

이번에 Github로 옮기면서 Github Action을 사용해봤어요~~

 

디스코드 연동때문에 여러 번 시행착오가 있었고

 

이런 추태를,,,

앞으로는 브랜치 파서 고치고 squash merge 해야겠다고 다짐한 초보 개발자였습니다. . .

 

이젠 잘 되는 모습!

 

그리고 서버 부분도 손봤는데요

역시나 클라이언트 부분보단 까다로웠습니다~~

 

이게 github action 짓거리를 브랜치 하나 파서 몰작(몰래 작업하다) 하려 했단 말이에요

근데 default 브랜치에서만 되더라고요?

 

그래서 추태를 또 부렸음

 

하... 새벽의 트러블슈팅 내용

이제 firebase를 사용하기때문에 해당 .json 파일을 따로 관리한단말임.

그래서 파이프라인 돌아가는 과정에서 넣어줘야되는데

당연히 secrets에 넣어서 파이프라인에서

 

 
시전했단 말이죠

근데 안되는거!!!!!!!!!!!!!!!!!!! 외않돼는데

매우매우 열받띠예라

json 파일을 출력..ㅋㅋ해봤음(님들은 지양하세요)

 

그랬더니 글쎄

 

이 멍청한 자식이 내 json파일을 yml로 인식하는거!!!!!!!!
그러니깐 오류가뜨지!!!!!!!!!!!!아오

 

근데 멍청한 지피티녀석은 이걸 몰랐던거임

이제 또 자기반성하는데

 

 
구글에 이렇게 치기만 하면 바로 나오는 문제를

GPT한테만 물어봐서,,, 이걸 몇 십 분동안 찾고있었다.

아니 심지어 github에서 secrets에 json 저장하는거 지양하라고 써있는데

이걸 왜 gpt는 안알려준건지

내 프롬프트 능력이 부족한건지.. ..

 

암튼

그래서

구글 내 다른 선생님들의 해결책으로는

json 그대로 적용해주는 라이브러리를 사용해라 << 였고,,

 

https://github.com/marketplace/actions/create-json

 

 

create-json - GitHub Marketplace

Create an JSON file from secret or a string of a json

github.com

해당 라이브러리를 만든 사람의 깃허브에 아주~~~~ 잘 설명이 돼있어요.

 

하니깐 바로 됐음핑

에휴! 내 시간!

 

주인장은 요즘 리팩토링에 빠졌습니다.

아침에 일어나서 자기 전까지 하루종일 리팩토링만 하고 있는 것 같아요(거짓말입니다)

제 코드 및 다른 팀원들의 코드를 내맘대로 리뷰하는 중 여러 Handler를 사용하면 코드가 더 깔끔하고 유지보수가 편해질 것 같아 상의도 없이 만들고 PR날린 그것,

ErrorHandler, Validator

기존 코드를 봅시다

@Service
@RequiredArgsConstructor
public class BattleServiceImpl implements BattleService {

	//생략

	@Override
	public void registBattle(BattleInviteRequest battleInviteRequest, User user) {
    
    	//생략
    
    	if (battleInviteRequest.getOppositeUserId() == user.getId()) {
			throw new IllegalArgumentException("Not valid user");
		}
		if (battleInviteRequest.getTime() <= 0) {
			throw new RuntimeException("Wrong time setting");
		}

 

아...

진짜 아... 싶죠? 분명 던지는 에러들이 서버 상 오류가아닌 비지니스로직 적 이루어질 수 있는 에러인데, 저렇게 서비스단에 2~3줄 심지어 if문으로 메서드 안에 넣으면...... 나중에 혹시라도 수정하게되거나 로직을 변경해야 되는 일이 생겼을 시 굉장히 귀찮아지겠죠?ㅎㅎ....

 

여기서 제가 생각해낸 방법은

1. 체크해야되는 조건들을 하나의 파일로 묶는다.

2. 오류 결과들을 하나의 파일에 정리한다

3. 던진다!

 

의 과정입니다.

 

먼저, 저 말도안되는 Not a valid user과 Wrong time setting을 과감히 지우고, "ErrorCode"의 Enum을 만들었습니다.

@Getter
@AllArgsConstructor
public enum ErrorCode {

    INVALID_BATTLE_TIME("최소 10분부터 최대 60분까지 설정 가능합니다."),
    INVALID_USER("유저 설정이 유효하지 않습니다."),
    INVALID_BATTLE_STARTTIME("시작 시간은 최소 60분 후부터 가능합니다."),
    INVALID_OPINION_LENGTH("선택지는 최대 16자까지 작성 가능합니다."),
    DUPLICATED_BATTLE("이미 해당시간에 예정된 배틀이 존재합니다."),
    ENDED_BATTLE("응답 기간이 종료된 배틀입니다.");

    private final String message;
}

 

Wrong time setting보다 저렇게 친절하게 message를 적어준다면 프론트분들도 처리하기 더더욱 쉽겠죠?ㅎㅎ

public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
       super(errorCode.name());
       this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
       return errorCode;
    }
}

 


그리고, 저 errorcode를 throw할 수 있는 custom exception을 정의해줍니다.

 

여기서 잠깐! RuntimeException을 extends하는 이유!
왜 하필 많은 오류 중 Runtime exception일까! 저는 굉장히 궁금했습니다. 사실 이름만 보면 그냥 Exception이 더 일반적으로 보이는데 쓰면 계속 throw를 하래잖아요! 그래서 패트병 대신 텀블러로 커피를 1회 마시고 GPT선생님께 여쭈어봤죠.

 

역시 우리으 친절하신 즤선생님,,

 

 

RuntimeException과 IllegalArgumentException과 같이 평소에 우리가 사용해도 try-catch 등을 쓰지 않아도 되는 Exception들은 Unchecked Exception이라고 합니다! 그에 반해, 일반 Exception은 Checked Exception이라고 try-catch나 throws를 해야된다고 하네요.

 

그래서 아무튼 Exception이 아닌 것들 중 가장 무난한 RuntimeException을 채택하였습니다.

 

그 후 원래 제가 만들어놨던 GlobalExceptionHandler 안에 제 작고 소중한 CustomException도 추가해줬어요...><

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
	public ResponseEntity<ApiResponseDto> handleRuntimeException(RuntimeException error) {
		ApiResponseDto res = new ApiResponseDto("fail", error.getMessage(), null);
		return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
	}
    
    //생략

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ApiResponseDto<String>> handleCustomException(CustomException error) {
        ApiResponseDto<String> response = new ApiResponseDto<>("fail", error.getErrorCode().getMessage(), null);
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
}

 

첫번째 메서드가 제가 직접 사용하던 RuntimeException 이고요, 밑이 제가 이제 사용하는 CustomException입니다!!

사실 기능상의 차이는 없어요~! RuntimeException도 message를 반환하고, 제 custom handler도 완전 같은 리턴타입이기에..

하지만, 귀엽잖아요.

 

다꾸, 폰꾸에 이어 에꾸(에러 꾸미기)까지... 즐겁지않나요?><

 

그리고 이제 Error을 Throw해주는데 사용될 Validator을 만들어줍니다~

@Component
@AllArgsConstructor
public class BattleValidator {

    static Date now = new Date();
    static Calendar calendar = Calendar.getInstance();
    private final BattleRepository battleRepository;

    public void validateOppositeUser(BattleInviteRequest battleInviteRequest, User user) {
       if (battleInviteRequest.getOppositeUserId() == user.getId()) {
          throw new CustomException(ErrorCode.INVALID_USER);
       }
    }

    public void validateTime(int minutes) {
       if (minutes < 10) {
          throw new CustomException(ErrorCode.INVALID_BATTLE_TIME);
       }
    }

 

아까랑 똑같은 코드에 똑같은 결과를 반환하는데, 이렇게 설정하는것이 훨씬 간지작살나지 않나요?

이제 서비스단에 이 Validator 내 메서드를 적용하면...!!

 

@Transactional
@Override
public void registBattle(BattleInviteRequest battleInviteRequest, User user) {

	//생략...

    battleValidator.validateOppositeUser(battleInviteRequest, user);
    battleValidator.validateTime(battleInviteRequest.getTime());
    battleValidator.validateStartTime(battleInviteRequest.getStartDate());
    battleValidator.checkOtherBattles(user, battleInviteRequest.getStartDate(), endDate);
}

 

TADA!!!!!

나만의 귀여운 코꾸(코드꾸미기...), 완성!!!

아까 말도안되는 if문과 비교해보면,, 훨씬 귀엽고 깜찍해졌죠?

 

이렇게 작성한 코드는 후에 다른사람이 보았을 때도 이해하기 쉽고, 유지보수도 용이해집니다~

 

사실 저는 1년전 이맘때쯤, 굉장한 홍머병에 걸렸었는데요,

그때는 플젝때도 남들이 내 코드를 이해하기 어렵고, 건드리지 못하는 것이 뭔가 고수같고 개짱짱맨 코드인 줄 알았답니다...

하하 코드는 무조건 간결!!해야하고 모듈화가 잘 되어있어야합니다~

 

이거는 알림 기능에서도 적용할 수 있는데요!

어떠한 알림을 보낼 지 프론트님님님들과 상의 후

public enum NotificationType {
    BATTLE_REQUEST(0, "%s님이 배틀을 신청했어요! 지금 바로 확인해보세요."),
    LIVE_NOTICE(1, "[%s] 라이브 시작 5분 전입니다!"),
    BATTLE_ACCEPT(2, "[%s] 배틀이 승낙되어 대기중입니다."),
    BATTLE_DECLINE(3, "[%s] 배틀이 거절되었습니다."),
    BATTLE_UNSATISFIED(4, "[%s] 배틀이 인원수를 충족하지 못해 모닥불로 이동하였습니다."),
    BATTLE_SATISFIED(5, "[%s] 배틀이 인원수를 충족하여 불구경 대기중입니다.");
    //todo 인원 수 미달 -> 모닥불 (참여자, 개최자)
    //todo 인원 수 충족 -> 라이브 개최 (참여자, 개최자)

    private final int code;
    private final String messageTemplate;

    NotificationType(int code, String messageTemplate) {
       this.code = code;
       this.messageTemplate = messageTemplate;
    }

    public int getCode() {
       return code;
    }

    public String getMessageTemplate() {
       return messageTemplate;
    }
}

 

이렇게 enum으로 정리해놓으며, 추가/수정하면 편하답니다~

 

그럼 여기서 퀴즈! 알림 메서드들의 문구들을 어떻게하면 더 그럴싸하게 작성할 수 있을까요?(제발도와주세요 저거 넘 구린거같아)

 

그럼 담에 또봐요 안뇽~~~

바바룽

 

인스타그램이나 유튜브와 같은 매체에서 검색 시,

G O O O O ... L E 와 같이 클릭하여 페이지를 넘기는 형식과 다르게

끝도 없이 스크롤이 생기며 컨텐츠들이 로드되는데요!

 

이런 무한스크롤을 구현하기 위해서 필요한 것이 Pagination 입니다!!

사실 무한스크롤 뿐만 아니라 그냥 값들을 다 받아올 때 Pagination을 쓰는 것을 추천드립니다.

데이터가 몇백,, 몇천을 넘어 몇백만개,, 이거 다 한번에 넘길 거 아니잖아요?

 

모든 코드는 Java, Spring(boot) 개발 환경을 기준으로 작성합니다!!

 

먼저, 프론트로부터 페이지 값을 넘겨받습니다.

가령, 유저가 처음으로 데이터들을 요청을 했을 시 프론트가 백으로 요청해야되는 page 값은 1이되겠죵!(default 값)

무한스크롤로 구현할 시 현재 2페이지 마지막 부분을 유저가 보고있다면, page = 3으로 요청을 해야겠죵

 

여기서 두 가지로 방법이 나뉩니다.

 

1. 처음부터 pageable 개체로 받기

@PostMapping("/get-list-by-pageable")
public ResponseEntity<?> getListByPageable(Pageable pageable, @RequestBody Object otherMethods) {
	Page<?> page = pageServiceImpl.getListByPageable(pageable, otherMethods);
    // 이하생략...
}

 

2. int page, int sort, int size 등 param값으로 넘겨받기

@PostMapping("/get-list-by-params")
public ResponseEntity<?> getListByParams(@RequestParam("page") int page, @RequestParam("size") int size, @RequestParam("sort") int sort, @RequsetBody Object otherMethods) {
	Page<?> page = pageServiceImpl.getListByParams(page, size, sort, otherMethods);
    // 이하 생략...
}

 

사실 뭐가됐듬 상관 없습니다 하지만 전 2번이 더 좋더라고용 처리하기가 쉬워서

 

그 다음, 서비스 단에서 Pageable 객체를 생성합니당

public Page<?> getListByParams(int page, int size, int sort, Object otherMethods) {
	//pageable 객체를 만들어주기~
	Pageable pageable = PageRequestOf(page, size, sort);
    List<?> list = pageMapper.getList(otherMethods);
    
    return new PageImpl<>(list, pageable, list.size());
}

 

만약 본인이 Controller단에서부터 Pageable로 받는다!! 하면 Pageable 객체를 만들어 줄 필요가 없겠죠~~

List를 가져온 다음, 만들거나 받아온 pageable 객체와 함께 Page를 만들어서 return하면 된답니다!

 

Swagger로 돌려보면!

    "pageable": {
        "sort": {
            "empty": true,
            "sorted": false,
            "unsorted": true
        },
        "offset": 0,
        "pageNumber": 0,
        "pageSize": 5,
        "paged": true,
        "unpaged": false
    },
    "last": false,
    "totalElements": 99,
    "totalPages": 20,
    "size": 5,
    "number": 0,
    "sort": {
        "empty": true,
        "sorted": false,
        "unsorted": true
    },
    "first": true,
    "numberOfElements": 99,
    "empty": false

 

이런식으로 list와 함께 현재 페이지 정보가 같이 return되는 것을 알 수 있어요~

참 쉽죠!

 

+ Recent posts