전체 글
- 블로그 옮겼습니다 2024.02.11
- [Optional] Optional 사용법과 프로젝트 적용 예시 (with 모던 자바 인 액션) 2024.02.11 1
- [REDIS] Redis 트랜잭션 트러블 슈팅 2024.02.06 2
- [JPA / Querydsl] NoOffset을 사용한 조회 쿼리 성능 개선, BooleanExpression 2024.01.23
- [프로젝트 회고] Solved.ac api를 활용한 백준 그룹 만들기 프로젝트 2023.12.28 2
- [WebFlux] WebFlux 성능 하락 요인 2023.12.25 1
- [프로그래머스 Lv 4 / Java / 자바][카카오 인턴 2019] 호텔 방 배정 2023.12.24
- [SpringBoot / Google Cloud Vision API] WebFlux 적용기 2023.12.23
블로그 옮겼습니다
[Optional] Optional 사용법과 프로젝트 적용 예시 (with 모던 자바 인 액션)
작성 배경
Spring Data JPA에서 엔티티를 찾을 때 기본적으로는 Optional로 반환하는 것을 알 수 있는데, 쿼리 메소드를 직접 만들거나 QueryDsl을 사용하여 메서드를 생성할 때 반환값을 값(DTO, Entity)으로 반환할지 Optional로 반환할지 기준이 서지 않았다. 그러다보니 NPE를 발생시키지 않으면서 Optional을 불필요하기 사용하지 않을 확실한 기준을 세우고 싶었다.
Optional<T> findById(ID id); // Spring Data JPA가 만들어준 기본 메서드
List<MemberSimpleResponseDto> getCurrentMembers(Set<String> currentMemberIdSet); // 내가 만든 Querydsl 메서드
Optional<MemberDetailResponseDto> getMemberDetail(String memberId); // 내가 만든 Querydsl 메서드

또한 반환값이 Optional이어도 이를 get 메서드로 가져와서 사용했었는데, Optional 안의 값이 null이면 에러가 발생하는 것은 기존과 동일했고 심지어 이는 NPE가 아닌 NoSuchElementException 이었다. 결국 해당 에러가 발생했을 때의 처리를 따로 해줘야했는데..
분명 Optional은 null이 될 수 있는 값을 보다 효과적으로 처리하기 위한 장치임은 알고있었기에 뭔가 잘못 사용하고 있다는 생각을 지울 수 없었다..ㅎㅎ 때문에 모던 자바 인 액션의 Optional 섹션을 정리해보고 해당 프로젝트에 적용한 내용을 작성해보았다! 🏋️🏋️🏋️
Optional 과 Null
Null
- null은 왜 문제인가?
- 에러의 근원이고 가독성을 떨어뜨린다
- 그 값 자체에는 아무 의미가 없다
- 포인터를 숨긴다는 자바 철학에 위배된다 (Null POINTER Exception)
- 모든 참조형식에 적용될 수 있다
- 할당된 null이 다른 부분으로 퍼졌을 때 어떤 의미로 사용되었는지 알 수가 없다
Optional 사용 이유
- Optional을 사용하면 메서드 반환값 만으로도 null이 있을 수 있음을 알려줌
- 따라서 Optional을 사용하지 않는다면 반드시 그 값이 있다는 뜻이 됨 (혹은 그 값이 꼭 있어야 한다는 뜻이 되기도 한다!)
- 만약 Optional이 아닌 필드에서 NPE가 발생했다는 것은 그 값이 null 일 수 있어서가 아닌 다른 부분에서 문제가 생겼다는 것을 반증하는 것임으로 null인지를 확인하는 코드는 불필요한 것이다!
프로젝트에서 사용한 Repository 메서드를 보면서 적용해보자
Optional<T> findById(ID id); // 1
List<MemberSimpleResponseDto> getCurrentMembers(Set<String> currentMemberIdSet); // 2
Optional<MemberDetailResponseDto> getMemberDetail(String memberId); // 3
- findById 에서 Optional을 사용했다는 것은 해당 메서드로 엔티티(T)를 찾았을 때 그 값이 null일 수 있다는 뜻이다. 따라서 이에 대한 특정 예외를 던지거나 기본값을 넣어주는 처리 등을 해주어야 한다
- getCurrentMembers 에서 Optional을 사용하지 않았다는 뜻은 해당 메서드로 조회할 때 반드시 반환값이 존재한다는 뜻이다. 따라서 null이 발생했다면 이는 로직상에 문제가 있다는 뜻으로 null이 발생되지 않도록 디버깅해야한다.
- getMemberDetail도 findById와 마찬가지이다. 반환값이 null일 수 있음으로 이에 따른 처리를 해주면 된다.
Optional의 특징
- 스트림의 연산에서 큰 영감을 받았다
- 따라서 스트림의 map 등의 메서드를 지원한다!
- Optional.empty()
- Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드!
public final class Optional<T> {
private static final Optional<?> EMPTY = new Optional<>(null);
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
}
- Optional.of()
- 안의 값이 null 일 수 없다. 만약 null이면 바로 NPE 발생
- Optional.ofNullable()
- 안의 값이 null일 수 있다. 만약 null이면 빈 Optional 객체 반환
map & flatMap
- Optional은 map 함수를 지원한다!
- 마찬가지로 flatMap도 지원한다
- 스트림에서 map과 flatMap의 차이
- map
- 인수로 받은 함수의 스트림을 적용하면 스트림의 스트림이 생성됨
- flatMap
- 인수로 받은 함수를 적용해서 생성된 각각의 스트림에서 컨텐츠만 남음
- 따라서 차원이 늘어나지 않고 유지가 된다
- map
- map과 flatMap의 차이를 예시에서 더 자세히 살펴보자
- 첫 코드는 컴파일 되지 않고, 아래 코드는 컴파일에 성공한다. 이유는 무엇일까??
Optional<String> name =
Optional.of(person).map(Person::getCar) //Optional<Optional<Car>>
.map(Car::getInsurance) // 컴파일 X
.map(Insurance::getName);
Optional<String> name =
Optional.of(person).flatMap(Person::getCar) //Optional<Car>
.flatMap(Car::getInsurance) //Optional<Insurance>
.map(Insurance::getName) //Optional<String>
.orElse("UnKnown"); // 컴파일 O
- getCar는 원래 Optional<Car> 객체를 반환한다
- 따라서 map(Person::getCar)에서 Optional<<Optional<Car>>를 반환하게 되어, getInsurance는 반환값을 받을 수 없다
- 이와 달리 flatMap은 2차원 Optional을 1차원으로 평준화해준다
- 평준화란?
- 이론적으로 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성하는 연산
- flatMap을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환되나 값을 감싸고 있다면 그 값에 주어진 함수가 적용된다
메서드 설명
대표적으로 사용하는 Optional 처리 메서드이다. 간단히 메서드를 설명하고 이를 활용한 예시를 기술하겠다.
- get( T )
- 가장 간단하지만 NoSuchElementException 발생 가능하다
- 따라서 반드시 Optional에 값이 있는게 아니라면 사용하지 말것
- orElse( T )
- 값이 없을 때 기본값 제공 가능
- orElseGet( supplier )
- Lazy 버전으로 값이 실제로 없을 때만 supplier가 실행
- 디폴트 메서드를 만드는데 비용이 클 때 사용
- orElseThrow( supplier )
- get 메서드와 비슷한데 값이 없을 때 발생하는 예외를 지정할 수 있다
- ifPresent( consumer )
- 값이 존재할 때 동작을 하고 없으면 아무일도 일어나지 않는다
프로젝트에서 사용한 Service단 메서드를 보면서 어떻게 활용했는지 함께 보자!
1. orElseThrow를 사용하면 사용자 지정 Exception을 던지기 좋다.
멤버를 삭제하는 서비스 메서드가 있다고 생각해보자. 사용자는 존재하지 않는 멤버를 삭제하라는 명령을 보낼 수도 있다.
이 때 삭제하려는 멤버가 존재하는지 확인을 하고 삭제하는 것은 쿼리를 2번 발생시킴으로 매우 비효율적이다.
그렇다면 findById에서 null을 가져올 수도 있다는 뜻인데, 만약 findById를 그냥 get으로 가져오게 되면 NoSuchElementException 에러가 발생하는데, 이는 다른 엔티티들을 찾지 못했을 때도 모두 동일한 에러가 발생함으로 우리는 에러만으로 어떠한 이유로 에러가 발생하였는지 알기 어렵다.
따라서 orElseThrow 사용자 지정 Exception (RestApiException) 을 던지도록 하여 간결하고 명확하게 오작동한 이유를 기술할 수 있다.
@Transactional
public void delete(String memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new RestApiException(CustomErrorCode.NO_MEMBER));
member.delete();
roomMemberRepository.deleteAll(member.getRoomMembers());
}
2. ifPresent를 사용하여 null이 아닐 때만 동작하게 할 수 있다
멤버의 팔로잉 수, 팔로워 수를 가져오는 쿼리 2개를 응답 dto에 넣어주는 private 메서드이다.
Querydsl에서 fetchOne한 값을 Optional.ofNullable로 감싸주어 NPE를 방지해주었다.
만약 팔로워, 팔로잉이 존재할 때만 값을 설정해주기 위해 ifPresent를 사용하였다.
private void setFollowCount(MemberDetailResponseDto res, String memberId) {
Optional<Long> followerCount = Optional.ofNullable(jpaQueryFactory.select(follow.to.count())
.from(follow)
.where(follow.to.memberId.eq(memberId))
.fetchOne());
Optional<Long> followingCount = Optional.ofNullable(jpaQueryFactory.select(follow.from.count())
.from(follow)
.where(follow.from.memberId.eq(memberId))
.fetchOne());
followerCount.ifPresent(count -> res.setFollowerCount(count.intValue()));
followingCount.ifPresent(count -> res.setFollowingCount(count.intValue()));
}
Optional 합치기 without unwrap
모던 자바 인 액션에 들어있는 예시였는데 흥미로워서 정리해보았다. person과 car가 둘다 null이 아닐 때만 myMethod라는 메서드가 실행되게 하려면 어떻게 해야할까? 물론 if(person != null && car != null)으로 처리할 수도 있겠지만 Optional을 사용해보는건 어떤가! Optional의 Stream한 연산을 사용해보면 그렇게 어렵지 않다. 모던 자바 인 액션에서 제시하는 방법은 flatMap & map 활용하는 것이다.
Optional<Person> person;
Optional<Car> car;
person.flatMap(p -> car.map(c -> myMethod(p,c)));
- 첫번째 flatMap에서 만약 optional에 값이 없다면 람다 표현식 실행안되고 바로 빈 optional 반환
- 값이 있다면 평준화되어 이어지겠지?
- 두번째 map에서 값이 없다면 역시 빈 optional을 반환함으로 빈 optional 반환
- 값이 있다면 내부 메서드 실행!
필터링
- Optional은 매우 Stream하다! stream 처럼 filter를 사용할 수 있음으로 활용해보자.
- optional에 값이 없다면 filter는 아예 작동하지 않는다
- filter를 거치고 남은 값을 활용할 수 있을 것이다!
- 만약 이름이 John인 사람이 있다면 그 이름을 출력한다고 하면?
Optional.of(person).filter(person -> "John".equals(person.getName()))
.ifPresent(System.out::println);
실용 예제
- null이 발생할 수 있는 값을 if-else-then 대신 ofNullable을 이용하여 깔끔하게 처리하자
Optional<Object> value = Optional.ofNullable(map.get("key"));
- 어떤 값이 null일 수 있고, null이거나, 값을 int로 변환하는데 실패하거나, 값이 없으면 0을 반환해야 한다면?
- 아래와 같이 함수형 프로그래밍으로 할 수도 있다!
return Optional.ofNullable(props.getProperty(name)) .flatMap(OptionalUtility::stringToInt) // Optional 값이 int로 바뀔 수 있으면 파싱, 안되면 empty 반환하는 "사용자" 메서드 .filter(i -> i > 0) .orElse(0);
주의할 점
- Optional은 직렬화할 수 없다
- 필드 형식으로 사용할 것을 가정하지 않았음으로 Serializable 인터페이스를 구현하지 않았기 때문
- 따라서 직렬화 모델이 필요하다면 Optional으로 값을 받을 수 있는 메서드를 추가하는 것이 좋다
- 기본형 Optional은 사용하지 말자
- map, flatMap, filter 등을 지원하지 않는다
참고자료
- 모던 자바 인 액션
'Java' 카테고리의 다른 글
[정렬 구현 / Java / 자바] Counting Sort 구현하기 (0) | 2023.09.29 |
---|
[REDIS] Redis 트랜잭션 트러블 슈팅
레디스 트랜잭션 관리하며
레디스를 이용하여 현재 방에 입장한 인원 정보를 저장하면서 트랜잭션 관리를 해보게되었다.
그런데 기존 JPA, RDBMS의 트랜잭션 관리와는 차이가 있어 공부(뻘짓)해보며 알게 된 내용을 정리해보았다.
레디스.. 너란 녀석 단순한줄만 알았는데..

Redis Transaction
MULTI (트랜잭션 시작)
Redis의 트랜잭션을 시작하는 커맨드로 트랜잭션을 시작하면 Redis는 이후 커맨드는 바로 실행되지 않고 queue에 쌓이며, 이후에 EXEC 호출 시 명령어를 순차적으로 실행.
EXEC (커밋)
정상적으로 처리되어 queue에 쌓여있는 명령어를 순차적으로 실행. RDBMS의 Commit과 동일한 역할.
DISCARD (명시적 롤백)
queue에 쌓여있는 명령어를 실괄적으로 폐기. RDBMS의 Rollback과 “유사”.
WATCH (낙관적 락)
Redis에서 Lock을 담당하는 명령어로, 낙관적 락(Optimistic Lock) 기반.
WATCH 명령어를 사용하면 UNWATCH 되기 전까지 1번의 EXEC 또는 Transaction 아닌 다른 커맨드만 허용
명시적 롤백 (DISCARD) 예시
127.0.0.1:6379(TX)> SET TESTDATA TEST1
QUEUED
127.0.0.1:6379(TX)> SET TESTDATA@ TEST!
QUEUED
127.0.0.1:6379(TX)> GET TESTDATA
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
3) "TEST1"
에러발생시 롤백? ⇒ DISCARD 됨예시
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> GET TESTDATA
QUEUED
127.0.0.1:6379(TX)> SET TESTDATA TEST1
QUEUED
127.0.0.1:6379(TX)> dasd (잘못된 커맨드 입력)
(error) ERR unknown command 'dasd', with args beginning with:
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
트랜잭션의 특징
- Redis의 트랜잭션은 잘못된 명령어가 있어도 정상적으로 사용한 명령어에 대해서는 잘 적용된다!
- 즉 Redis는 보통 RDBMS의 트랜잭션 롤백형태를 채택하지 않는다
- rollback을 채택하지 않음으로써 빠른 성능을 유지
- 롤백 지원은 Redis의 단순성과 성능에 큰 영향을 미치기 때문에 Redis는 트랜잭션 롤백을 지원하지 않음
그러하다.. 호오.. 설명만으로는 잘 이해가 가지 않을 수 있을 것 같다. 그러면 직접 구현해본 아래 코드를 보면서 더 알아보자.
@Transactional 을 사용하는 방법도 있다고 하지만, 보다 Redis 트랜잭션을 더 잘 이해해보기 위해서 필자는 직접 SessionCallback 익명 클래스를 구현하여 트랜잭션을 관리해보았다.
어노테이션 안쓰고 WATCH, MULTI, EXECUTE 하기
트랜잭션이 필요한 메서드를 CustomRedisRepository 로 따로 구현하였다.
multi() → execute() 사이를 트랜잭션 관리해준다
원리
MULTI 선언 후 EXECUTE 전까지는 쿼리가 queue에 쌓이게 된다
만약 그 사이에 에러가 발생하면 queue에 쌓인 쿼리가 DISCARD 되어 삭제된다
즉, 쿼리가 적용되지 않는다!
- 여기서 주의할 점은, redisTemplate에서 MULTI, EXECUTE 사이의 GET 요청은 NULL 값을 가져올 것이라는 것이다.
- 이는 EXECUTE 가 실행되기 전까지는 실제 쿼리가 날아가지 않는데, 쿼리를 실행해서 조회하는 코드를 넣었으니 리턴값을 미리 읽으려고 하면 Null 값이 들어가 있는 게 당연하다!
- Returns: null when key or hashKey does not exist or used in pipeline / transaction. (java doc 설명)
번외로 만약 multi() 없이 execute() 메서드 사용하면 Error 발생한다.. (심지어 Unknown Error 이렇게 살벌하게 발생한다 이뇨속)
방 들어오는 로직
public void enterMember(String memberId, Long roomId, LocalDateTime enterTime) {
String currentMemberKey = KeyUtil.getCurrentMemberKey(memberId);
String roomKey = KeyUtil.getRoomKey(roomId);
// 트랜잭션 관리
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.watch(roomKey); // 공통자원에 낙관적 락
operations.multi();
// 현재 멤버가 어디에 있고, 언제 들어갔는지 저장
operations.opsForHash().put(currentMemberKey, "roomId", roomId.toString());
operations.opsForHash().put(currentMemberKey, "enterTime", DateParser.stringParse(enterTime));
// 현재 어떤방에 어떤 멤버가 들어왔는지 저장
operations.opsForSet().add(roomKey, memberId);
return operations.exec();
}
});
}
방 나가는 로직
public void leaveMember(String memberId, Long roomId) {
String currentMemberKey = KeyUtil.getCurrentMemberKey(memberId);
String roomKey = KeyUtil.getRoomKey(roomId);
// 트랜잭션 관리
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.watch(roomKey); // 공통자원에 낙관적 락
operations.multi();
// 해당 방 들어있는 유저정보에서 유저를 삭제
operations.opsForSet().remove(roomKey, memberId);
// 현재 유저의 시간 정보를 추출하고 위치정보와 시간정보를 삭제
operations.opsForHash().delete(currentMemberKey, "roomId");
operations.opsForHash().delete(currentMemberKey, "enterTime");
return operations.exec();
}
});
}
테스트
중간에 일부로 에러를 던져보았다
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.watch(roomKey);
operations.multi();
operations.opsForSet().remove(roomKey, memberId);
if (true){
throw new RestApiException(CommonErrorCode.INTERNAL_SERVER_ERROR);
}
operations.opsForHash().delete(currentMemberKey, "roomId");
operations.opsForHash().delete(currentMemberKey, "enterTime");
return operations.exec();
}
});
Redis CLI을 조회해보면 ROOM에 들어있는 유저정보의 Key인 “ROOMID:19”안에 유저 데이터가 살아있음을 확인할 수 있다

이슈 (짧은 트러블슈팅)
한창 디버깅을 하는 중이었는데 서비스 로직에서 key 를 제대로 읽어오지 못하는게 이상해서 Redis 의 모든 값을 초기화하고 모든 키를 불러왔는데… (FLUSHDB 후에 KEYS * 명령어 입력하니)

RedisConfig에서 Serialize가 제대로 구현되어있지 않아 직렬화 과정에서 문제가 있었었다.
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setEnableTransactionSupport(true); // redis @Transaction 사용시
return redisTemplate;
}
해결방법
어자피 key, value를 String, String으로 가져올 것임으로 StringRedisTemplate을 쓰자..!
@Bean
public RedisTemplate<String, String> redisTemplate() {
StringRedisTemplate redisTemplate = new StringRedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setEnableTransactionSupport(true); // redis @Transaction 사용시
return redisTemplate;
}
참고 자료
https://velog.io/@worldicate/Redis에서-Transactional-iwrn8fxz
https://data-make.tistory.com/757#recentComments
https://wildeveloperetrain.tistory.com/32
https://docs.spring.io/spring-data/redis/reference/redis/transactions.html
[JPA / Querydsl] NoOffset을 사용한 조회 쿼리 성능 개선, BooleanExpression
팀 프로젝트에서 검색 조건에 따라 방의 정보를 리턴하는 api를 작성해야했다.
검색 조건에 따른 방을 read하는 동적 쿼리작성 및 동시에 페이징해와야하는 쿼리였는데, 성능 개선과 코드 가독성을 위한 내용을 정리해보았다.
아이템
1. NoOffset 적용
2. BooleanBuilder→ BooleanExpression 으로 코드 변경
1. NoOffset을 활용한 조회 쿼리 성능 개선
아래는 noOffset이 적용되지 않은 기존의 Slice를 리턴하는 코드이다.
추가적으로 Page를 사용하지 않고 Slice를 사용한 이유로는, Slice는 Page와 달리 추가 page가 있는지에 대한 정보 확인을 위한 count 쿼리가 추가적으로 발생하지 않아 성능적으로 이점을 가져갈 수 있다.
@Override
public Slice<RoomSearchResponseDto> findRoomCustom(RoomSearchRequestDto req, Pageable pageable) {
List<RoomSearchResponseDto> content = jpaQueryFactory.select(Projections.constructor(RoomSearchResponseDto.class,
room.id,
member.memberId,
member.nickname,
room.title,
room.description,
room.link,
room.roomImage,
room.mcount,
room.capacity,
room.isLocked,
room.password,
room.constraints,
room.type, room.createdAt)).distinct().
from(room)
.innerJoin(member).on(room.manager.memberId.eq(member.memberId))
.leftJoin(roomKeyword).on(roomKeyword.room.eq(room))
.leftJoin(keyword).on(roomKeyword.keyword.eq(keyword))
.where(
eqKeyword(req.getSearchKeyword()),
isLocked(req.getIsLocked()),
eqConstraints(req.getConstraints()),
eqKeywordIds(req.getKeywordIds()),
eqType(req.getType()),
btwMcount(req.getMinMcount(), req.getMaxMcount()),
btwCapacity(req.getMinCapacity(), req.getMaxCapacity()))
.orderBy(makeOrder(req))
.offset(pagable.getOffset())
.limit(pageable.getPageSize() + 1) // 1개를 더 가져온다
.fetch();
boolean hasNext = content.size() > pageable.getPageSize(); // 뒤에 더 있는지 확인
content = hasNext ? content.subList(0, pageable.getPageSize()) : content; // 뒤에 더 있으면 1개 더 가져온거 빼고 넘긴다
return new SliceImpl<>(content, pageable, hasNext);
}
코드가 길지만 우리는 이 부분만 집중해서 보면 된다
.offset(pagable.getOffset())
.limit(pageable.getPageSize() + 1) // 1개를 더 가져온다
.fetch();
boolean hasNext = content.size() > pageable.getPageSize(); // 뒤에 더 있는지 확인
content = hasNext ? content.subList(0, pageable.getPageSize()) : content; // 뒤에 더 있으면 1개 더 가져온거 빼고 넘긴다
return new SliceImpl<>(content, pageable, hasNext);
Pagable에서 offset, pageSize를 가져오고 pageSize보다 1개 더 크게 데이터를 가져온다.
해당 데이터 값이 pageSize + 1만큼 모두 가져왔다면 뒤에 데이터가 더 남아있다는 뜻으로,
hasNext를 true로 설정하여 SliceImpl을 반환한다.
끝으로, content는 1개 더 가져온 데이터를 제외한 페이지 크기만큼의 데이터를 반환한다.
그렇다면 NoOffset이란 무엇일까?

offset 10000
limit 20
만약 위와 같은 조건을 가지는 예시에서 크기가 1000개인 11번 페이지를 읽어야 한다고 가정해보자.
이를 위해 우리는 10020개의 컬럼을 읽어야 하며, 이 중 10000개의 컬럼은 불필요하게 탐색을 하게 되는 것이다.
그리고 그림에서 알 수 있듯이, 뒤에있는 페이지를 탐색할 수록 불필요한 컬럼을 읽음으로서 성능은 더욱 떨어지게 될 것이다.
즉 NoOffset에 대해서는 아래와 같이 정리할 수 있다.
" NoOffset은 offset의 불필요한 컬럼 탐색을 스킵하여 조회 시작 부분을 인덱스로 빠르게 찾아 필요한 페이지의 정보만 select해오는 방법이다. "
구현하는 방법으로는 조회 조건문에 맨 처음에 PK로 제한 조건을 거는 것인데,
이는 PK가 클러스터 인덱스로 조회 시작 지점을 빠르게 찾을 수 있다.
querydsl 코드에서 offset 을 제거하고 where절에 roomId보다 작은 값들을 찾으면 된다.
여기서 작은 값인 이유는 해당 정보가 id에 대하여 내림차순으로 정렬되어있기 때문이다.
아래는 NoOffset을 적용하여 변경된 코드이다.
public Slice<RoomSearchResponseDto> findRoomCustom(RoomSearchRequestDto req, Pageable pageable) {
List<RoomSearchResponseDto> content = jpaQueryFactory.select(Projections.constructor(RoomSearchResponseDto.class,
room.id,
member.memberId,
member.nickname,
room.title,
room.description,
room.link,
room.roomImage,
room.mcount,
room.capacity,
room.isLocked,
room.password,
room.constraints,
room.type, room.createdAt)).distinct().
from(room)
.innerJoin(member).on(room.manager.memberId.eq(member.memberId))
.leftJoin(roomKeyword).on(roomKeyword.room.eq(room))
.leftJoin(keyword).on(roomKeyword.keyword.eq(keyword))
.where(
ltRoomId(req.getPrevRoomId()),
eqKeyword(req.getSearchKeyword()),
isLocked(req.getIsLocked()),
eqConstraints(req.getConstraints()),
eqKeywordIds(req.getKeywordIds()),
eqType(req.getType()),
btwMcount(req.getMinMcount(), req.getMaxMcount()),
btwCapacity(req.getMinCapacity(), req.getMaxCapacity()))
.orderBy(room.id.desc()).
limit(pageable.getPageSize() + 1).
fetch();
boolean hasNext = content.size() > pageable.getPageSize();
content = hasNext ? content.subList(0, pageable.getPageSize()) : content;
return new SliceImpl<>(content, pageable, hasNext);
}
private BooleanExpression ltRoomId(Long prevRoomId) {
if (prevRoomId == null) return null;
return room.id.lt(prevRoomId);
}
BooleanExpression을 주어서 직전 페이지의 마지막 MemberId가 존재했다면 그 값을 받아와서 offset 대신 where 절 조건으로 활용하였다.
BooleanExpression에 대한 설명은 뒤에 나오니 참고해주기 바란다.
동일하게 NoOffset을 적용한 getFollowers 메서드로 간단한 테스트를 해보자.
아래는 한 멤버를 팔로우하는 팔로워 정보를 페이징하는 메서드이다.
noOffset을 적용하기 전 후의 성능차이를 비교해보자.
@Override
public Slice<MemberSimpleResponseDto> getFollowers(Pageable pageable, String memberId, String prevMemberId) {
List<MemberSimpleResponseDto> content = jpaQueryFactory.select(Projections.constructor(MemberSimpleResponseDto.class,
member.memberId,
member.nickname,
member.profileImage,
member.feature
)).from(follow)
.join(member)
.on(follow.from.eq(member))
.where( gtMemberId(memberId), // noOffset 적용
follow.to.memberId.eq(memberId)
)
.limit(pageable.getPageSize()+1)
.fetch();
boolean hasNext = content.size() > pageable.getPageSize(); // 뒤에 더 있는지 확인
content = hasNext ? content.subList(0, pageable.getPageSize()) : content; // 뒤에 더 있으면 1개 더 가져온거 빼고 넘긴다
return new SliceImpl<>(content, pageable, hasNext);
}
private BooleanExpression gtMemberId(String prevMemberId) {
if (prevMemberId == null) return null; // prevMember가 주어지지 않으면 null로 where 조건 무시됨
return member.memberId.gt(prevMemberId); //마지막으로 리턴된 prevMemberId 다음부터 탐색
}
테스트 코드
매우 간단하게 System.currentTimeMillis()로 검사하였다. 유저당 팔로우 수를 10000명으로 설정하여 테스트하였다.
@Test
@Transactional
void noOffset(){
Long srt = System.currentTimeMillis();
Slice<MemberSimpleResponseDto> legacy = followService.getFollower(FollowerRequestDto.builder().pageNo(99).pageSize(100).build(), "멤버10000");
log.info("legacy : {}",System.currentTimeMillis() - srt);
assertThat(legacy.getContent().size()).isEqualTo(100);
Long srt2 = System.currentTimeMillis();
Slice<MemberSimpleResponseDto> noOffset = followService.getFollower(FollowerRequestDto.builder().pageNo(99).pageSize(100).prevMemberId("멤버9801").build(), "멤버10000");
log.info("noOffset : {}",System.currentTimeMillis() - srt2);
assertThat(noOffset.getContent().size()).isEqualTo(100);
}
테스트 결과
2024-01-23T16:23:45.197+09:00 INFO 16816 --- [ main] c.a.c.member.service.FollowServiceTest : legacy : 482ms
2024-01-23T16:23:45.199+09:00 INFO 16816 --- [ main] c.a.c.member.service.FollowServiceTest : noOffset : 2ms
정밀한 결과는 절대 아니지만, 아래 결과를 보면 약 240배 가량 성능이 개선된 것을 확인할 수 있다.
이는 pk 기준으로 제일 뒤의 값을 조회하였음으로 더욱 크게 성능차이가 날 수 있다.
2. 다이나믹 쿼리 BooleanBuilder → BooleanExpression 로 수정하기
Querydsl에서는 where 조건문에 파라미터가 null이라면 조건절에서 무시된다.
이를 활용하여 다이나믹 쿼리를 작성할 수 있는데 방법은 크게 2가지가 있다.
1. BooleanBuilder
2. BooleanExpression
BooleanBuilder는 if문으로 필요한 부분을 추가하는 방식으로 마이바티스에서 자주 사용하는 방법과 유사하다.
그러나 가독성 방면에서는 BooleanExpression이 유리하기 때문에 해당 방법으로 코드를 리팩토링해보았다.
BooleanBuilder (기존 코드)
List<RoomSearchResponseDto> content = jpaQueryFactory.select(Projections.constructor(RoomSearchResponseDto.class,
room.id,
member.memberId,
member.nickname,
room.title,
room.description,
room.link,
room.roomImage,
room.mcount,
room.capacity,
room.isLocked,
room.password,
room.constraints,
room.type)).distinct().
from(room)
.innerJoin(member).on(room.manager.memberId.eq(member.memberId))
.leftJoin(roomKeyword).on(roomKeyword.room.eq(room))
.leftJoin(keyword).on(roomKeyword.keyword.eq(keyword))
**.where(makeBooleanBuilder(req))**
.orderBy(makeOrder(req)).
offset(pageable.getOffset()).
limit(pageable.getPageSize() + 1). // 1개를 더 가져온다
fetch();
private BooleanBuilder makeBooleanBuilder(RoomSearchRequestDto req) {
BooleanBuilder builder = new BooleanBuilder();
if (req.getSearchKeyword() != null) {
builder.andAnyOf(
room.title.contains(req.getSearchKeyword()),
room.description.contains(req.getSearchKeyword())
);
}
if (req.getIsLocked() != null)
builder.and(room.isLocked.eq(req.getIsLocked()));
if (req.getMinMcount() != null || req.getMaxMcount() != null) {
builder.and(room.mcount.between(req.getMinMcount(), req.getMaxMcount()));
}
if (req.getMinCapacity() != null || req.getMaxCapacity() != null) {
builder.and(room.capacity.between(req.getMinCapacity(), req.getMaxCapacity()));
}
if (req.getConstraints() != null && !req.getConstraints().isEmpty())
builder.and(room.constraints.in(req.getConstraints()));
if (req.getType() != null)
builder.and(room.type.eq(req.getType()));
if (req.getKeywordIds() != null && req.getKeywordIds().size() > 0){
builder.and(roomKeyword.keyword.id.in(req.getKeywordIds()));
}
return builder;
}
BooleanExpression (변경 코드)
위 메서드에서 변경된 부분만 가져왔다.
BooleanExpression을 반환하는 메서드명으로 한눈에 where 조건을 확인할 수 있다.
BooleanExpression이 null인 경우 where에서 조건검사시 무시함으로 보다 가독성 좋게 동적쿼리를 작성할 수 있다.
...
.where(eqKeyword(req.getSearchKeyword()),
isLocked(req.getIsLocked()),
eqConstraints(req.getConstraints()),
eqKeywordIds(req.getKeywordIds()),
eqType(req.getType()),
btwMcount(req.getMinMcount(), req.getMaxMcount()),
btwCapacity(req.getMinCapacity(), req.getMaxCapacity()))
...
private BooleanExpression eqKeyword(String keyword){
if(StringUtils.isEmpty(keyword)) return null;
return room.title.contains(keyword)
.or(room.description.contains(keyword));
}
private BooleanExpression isLocked(Boolean isLocked){
if(isLocked == null) return null;
return room.isLocked.eq(isLocked);
}
private BooleanExpression btwMcount(Integer min, Integer max){
if(min == null && max == null) return null;
if (min == null) return room.mcount.lt(max);
if (max == null) return room.mcount.gt(min);
return room.mcount.between(min, max);
}
private BooleanExpression btwCapacity(Integer minCapacity, Integer maxCapacity){
if(minCapacity == null && maxCapacity == null) return null;
if (minCapacity == null) return room.capacity.lt(maxCapacity);
if (maxCapacity == null) return room.capacity.gt(minCapacity);
return room.capacity.between(minCapacity, maxCapacity);
}
private BooleanExpression eqConstraints(List<RoomConstraints> constraints){
if(constraints == null || constraints.isEmpty()) return null;
return room.constraints.in(constraints);
}
private BooleanExpression eqType(RoomType type){
if(type == null) return null;
return room.type.eq(type);
}
private BooleanExpression eqKeywordIds(List<Long> keywordIds){
if(keywordIds == null || keywordIds.isEmpty()) return null;
return roomKeyword.keyword.id.in(keywordIds);
}
참조
https://www.youtube.com/watch?v=zMAX7g6rO_Y&t=3s
https://jojoldu.tistory.com/528
https://docs.spring.io/spring-data/jpa/reference/jpa.html
https://www.youtube.com/watch?v=rYj8PLIE6-k&t=5s
[프로젝트 회고] Solved.ac api를 활용한 백준 그룹 만들기 프로젝트

✅ 프로젝트 링크
https://github.com/damdam6/BaekJoon-Group-Board
GitHub - damdam6/BaekJoon-Group-Board
Contribute to damdam6/BaekJoon-Group-Board development by creating an account on GitHub.
github.com
✅ 프로젝트 설명
- 서비스 이름 : BBoard
- 서비스 설명 : 사용자가 그룹을 생성하여 그룹원들이 푼 백준 문제들을 통합하고 시각화하며, 사용자 레벨에 맞는 문제를 자동 추천해주는 서비스
- 기획 배경 : 알고리즘을 공부하는 과정에서 서로가 문제를 추천하거나, 각자 풀이한 문제를 비교하면서 동기부여를 얻는 경우가 다수 있음. 따라서 사용자가 그룹을 생성하여 서로 풀어본 문제들을 한눈에 보고 비교할 수 있고, 유사한 티어의 문제를 자동으로 추천해주는 웹페이지 제작을 기획
- 개발 기간 : 2023.11.13 ~ 2023.11.24 (2주)
- 인원 : 2명
✅ 역할
Back 구현 (70%)
외부 API 비동기 요청 및 응답 Logic 설계, 구현 및 최적화
Front (30%)
Login Page, Admin Page, Group Page 구현
✅ 주요 기술 스택
- SpringBoot 3.1.5
- Mybatis
- MySql
- Vue.js
- Tailwind
✅ 사용한 API (비공식 Solved.ac API Ver.3)

https://solvedac.github.io/unofficial-documentation/#/
@solvedac/unofficial-documentation
이 프로젝트는 solved.ac API를 문서화하는 커뮤니티 프로젝트입니다. 이 저장소는 원작자의 요청에 따라 언제든 지워질 수 있으며, 현재 API와 일치하지 않을 수도 있는 점 양해 부탁드립니다.
solvedac.github.io
위 API를 사용함에 있어서 주의할 점이 있다.
solved.ac api가 비공식 api임으로 이후 version이 변경되거나 서비스를 중단할 수 있다. 아무래도 공식적으로 solved.ac, 백준에서 제공하는 API가 아니니 어쩔 수 없는 부분이 있다. 또한 solved.ac api 호출 횟수 제한으로 인해 오류가 발생할 수 있다. 자세한 내용은 뒤에 설명해두었다.
1. 사용자 정보 가져오기 (GET)
https://solved.ac/api/v3/user/show?handle=[유저아이디]
2. 상위 100문제 가져오기 (GET)
https://solved.ac/api/v3/user/top_100?handle=[유저아이디]
3. 사용자 티어별 푼 문제 수 가져오기 (GET)
https://solved.ac/api/v3/user/problem_stats?handle=[유저아이디]
4. 사용자가 푼 문제 페이지별로 가져오기 (GET)
https://solved.ac/api/v3/search/problem?query=@[유저아이디]&sort=level&direction=desc&page=[페이지번호]
✅ 당면한 문제와 이를 해결한 방법
1. API 횟수 제한
문제상황
solved.ac 비공식 api는 테스트 결과 약 10분 기준으로 1000회 호출 제한이 있었다.. 이번 프로젝트에서 가장 큰 난관이었다.
외부 API 자체를 개선할 수 없기에, 제한된 조건에서 가장 효율적인 서비스를 만들어 내는 것을 목표로 하게 되었다.
기획한 서비스를 구현하기 위해서는 유저 1명당 아래의 정보가 필요했었다.
1. 유저 랭크 및 티어 정보
2. 유저가 푼 상위 100개 문제 정보
3. 유저가 각 티어별로 푼 문제 1개씩 필요
위 정보를 매번 업데이트 하기 위해서는 최소 3회에서 최악의 경우 34회의 호출이 필요했었다. 1번 정보를 위해 사용자 정보 API 호출 1회, 유저가 푼 상위 100개 문제 정보 API 호출 1회, 유저가 각 티어별로 푼 문제 정보수 1회 호출이 필요하고 티어별 문제를 가져오기 위해서는 유저가 푼 문제를 50개 단위로 가져올 수 있는데 티어가 31종류 (브론즈 5 ~ 루비 1 + 미분류)로 단순히 요청하게되면 31번을 호출해야하는 상황이었다. 이를 개선하기 위해 최소한의 요청으로 최대한 다양한 문제를 가져올 필요가 있었다.
개선
1. 지정시간에 reset하는 로직으로 수정
유저가 새로고침 할 때마다 해당 유저가 속한 그룹에 대한 모든 유저 정보를 매번 가져오는 것은 매우 비효율적이었다. API 횟수 제한도 있었지만, 서비스의 특성상 상 그룹 내 유저가 문제를 풀면서 정보가 업데이트 되는 일이 분단위로 일어나지 않고, 실시간 단위로 유저 정보를 반영하는 것이 중요하지 않기 때문이었다.
위 고찰을 바탕으로 유저가 새로고침할 때마다 외부 API를 요청하여 정보를 갱신하는 것이 아닌, 10분마다 서버가 자동갱신하는 것으로 로직을 수정하였, 이를 위해 @Scheduled 어노테이션을 사용하였다.
@Scheduled(fixedRate = 3600000)
@Transactional
public void schedulTask() {
// ..
}
2. Memoization 활용
다행히 제공하는 API는 유저가 푼 문제를 정렬하여 페이징하여 받아올 수 있는 기능을 제공하였다 (API 문서에는 나와있지 않다.. 많은 글들을 보다가 다행히 찾아냈다!). 덕분에 유저가 티어별로 푼 문제수 정보를 통해 몇번 째 페이지 정보를 가져와야 하는지 알 수 있었다.
아래는 사용자 티어별 푼 문제 수에 대한 API 응답 정보 중 일부이다.
...
{
"level": 4,
"solved": 22,
"tried": 0,
"partial": 0,
"total": 869
},
{
"level": 5,
"solved": 14,
"tried": 0,
"partial": 0,
"total": 781
},
{
"level": 6,
"solved": 22,
"tried": 1,
"partial": 0,
"total": 761
},
{
"level": 7,
"solved": 26,
"tried": 0,
"partial": 0,
"total": 838
},
...
예시로 level 0~3까지 푼 문제가 없고 4 ~ 7까지는 위와 같다면 우리는 아래와 같은 정보를 얻을 수 있다.
Lv 4 : 첫 페이지에서 1 ~ 22번 문제
Lv 5 : 첫 페이지에서 23 ~ 36번 문제
Lv 6 : 첫 페이지에서 37 ~ 50 + 둘째 페이지에서 1 ~ 9번 문제
Lv 7 : 둘째 페이지에서 10 ~ 35번 문제
...
위에서 Lv 4 ~ 6번 문제를 가져오기 위해서는 실제로는 첫 페이지를 가져오기 위한 1번의 API 요청만 있으면 된다.
위 예시를 코드로 구현하면 아래와 같다.
public List<UserTier> makeUserTierObject(List<UserTier> userTierList){
// User 1개 당
// 0 ~ 30 까지 총 31개의 Tier가 존재 가능한 것으로 보임
int[] problemPrefixSum = new int[MAX_TIER+2];
for (int i = MAX_TIER; i >= 0; i--) {
UserTier now = userTierList.get(i);
problemPrefixSum[i] = now.getProblemCount() + problemPrefixSum[i+1];
if (problemPrefixSum[i+1] == 0) continue;
now.setPageNo(problemPrefixSum[i+1] / NUMBER_OF_PAGES + 1);
now.setPageIdx(problemPrefixSum[i+1] % NUMBER_OF_PAGES == 0 ? 0 : problemPrefixSum[i+1] % NUMBER_OF_PAGES);
}
return userTierList;
}
모든 유저에 대해서 모든 티어의 문제가 몇번째 페이지 & 몇번째 문제에 있는지를 구하는 코드이다.
각 티어별로 동일한 페이지를 가지고 있다면 추가 API 요청을 보내지 않고 이미 받아온 정보를 캐싱하여 repository에 저장하였다 (메모이제이션). 구현 코드는 아래와 같다.
public List<ProblemAndAlgoObjectDomain> makeTotalProblemAndAlgoList(Map<User, Map<Integer,
List<ProblemAndAlgoObjectDomain>>> memoMap, Map<Integer, List<UserTier>> userTierMap){
List<ProblemAndAlgoObjectDomain> totalProblemAndAlgoList = new ArrayList<>();
for (User user: memoMap.keySet()) {
List<UserTier> userTierList = userTierMap.get(user.getUserId()); // 유저당 userTier 데이터 저장된 맵
int prevPage = 0;
List<ProblemAndAlgoObjectDomain> problemListByPage = null;
for (UserTier userTier : userTierList) {
if(prevPage != userTier.getPageNo()){
problemListByPage = memoMap.get(user).get(userTier.getPageNo());
prevPage = userTier.getPageNo();
}
if (userTier.getProblemCount() != 0) {
ProblemAndAlgoObjectDomain problemAndAlgo = problemListByPage.get(userTier.getPageIdx());
problemAndAlgo.getProblem().setUserId(user.getUserId());
totalProblemAndAlgoList.add(problemAndAlgo);
}
}
}
return totalProblemAndAlgoList;
}
코드에는 생략되었지만 위 makeUserTierObject에서 생성된 userTierList를 userTierMap에 user를 키로 저장해두었다.
userTierList를 순서대로 탐색하면서 서로 다른 페이지일 경우에만 totalProblemAndAlgoList에 추가함으로써 이후 한번에 비동기적으로 외부 API로 요청을 보낼 것이다. 이렇게 불필요한 API 요청을 줄임으로써 API 횟수 제한의 risk를 방지할 수 있었다
2. API 응답 시간
문제상황
API 요청 횟수를 제한 조건 아래로 줄였다고 해도 외부 API I/O는 delay가 큰 작업이다. 초반에는 사용자 정보 업데이트를 RestTemplate로 구현하여 동기적으로 요청함으로서 서비스 최대 인원인 30명의 사용자 정보를 가져올 때 150초 소요되는 문제가 있었다. 또한 사용자 정보를 업데이트하는 과정에서 메인 쓰레드가 다른 작업을 처리하지 못하고 서비스가 중단되었다.. 10분마다 일어나는 업데이트를 비동기적으로 개선해야할 필요가 있었다.
개선
개선을 위해 WebClient & Reactor를 이용하여 비동기적으로 api를 호출하게 코드를 수정하였다. Flux를 이용하여 iterable한 데이터에 대해 비동기적으로 처리한 결과, 사용자 정보 호출을 15초로 90% 가량 감소하였다. 또한 사용자 정보 업데이트가 비동기적으로 처리되기 때문에 업데이트가 진행되는 동안 서비스가 멈추는 현상이 해소되었다.
public Flux<JsonNode> fetchOneQueryData(String pathQuery, String query) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path(pathQuery)
.query(query)
.build()).retrieve()
.bodyToFlux(JsonNode.class);
}
소감
우선, 차근차근 기다려주고 잘 이끌어준 담비에게 고맙다! 🙇♂️
첫 프로젝트였다. 얼마나 허접한가... ㅠㅠ 단순히 Springboot, MySql를 설치하는 것부터 git 컨벤션을 지키고 브랜치 관리하는 것까지 모두 처음 하는 만큼 익숙치 않은 것들 뿐이었다. 심지어 Reactor를 활용한 코드는 정상적으로 구동은 하지만 return 값이 void이고 메소드 내에서 subscribe할만큼 매우 이상한 코드였다. 그러나 어찌 되었든 돌아가는 코드를 만들어 내는 것이 정말 중요하다는 것을 느꼈고, 내놓기 부끄러울지라도 무언가를 만들어가면서 책상 앞에서 공부하는 것보다 훨씬 많은 것을 배웠다. 자극받은 만큼 1학기 방학기간동안 프로젝트를 하나 더 따로 진행하고 있고, 이 내용도 공유할 수 있도록 하겠다!
아쉬운 점으로는 개발 기간이 부족하여 사용자가 직접 추가한 문제를 추가하지 못했었다. 프로젝트 설정기간인 2주가 지난 후에 팀원과 논의하여 방학기간에는 코드 리팩토링과 추가 프로젝트에 집중하는 것으로 하여 최종적으로는 추가하지 않기로 했다. 다음 프로젝트에는 더 짧은 시간 내에 집중하여 구현할 수 있을 것 같다! 또한 Reactor 를 너무 엉성하게 짠 느낌이 강했다. 비동기 프로그래밍에 뒤통수를 쎄게 맞고 제발 돌아만 가달라는 식으로 이틀을 붙잡고 있다보니.. 다음 프로젝트에서는 보다 깔끔하게 구현하고 싶은 욕심이 생겼고, 백기선 강사님의 유튜브를 보면서 기본 원리부터 차근차근 배워나가고 있다.
프로젝트 외적으로, 프로그래밍으로 진로를 아예 바꾸면서 느낀 점이 많다. 공부를 하다가 막히는 것이 생겼을 때, "이건 내가 몰라도 돼" 라고 생각하지 않고 어떻게든 찾아보려고 이만큼 노력했던 적이 있었던가? 생각해보면 그럴 필요가 없었었다. 그렇게 더 공부한다고 인정해주는 사람도 없었을 뿐더러, 나도 그렇게까지 내 전공에 흥미를 가지지는 않았던 것 같았다. 자기주도적으로 공부한 것은 맞지만, 주어진 수업과 교재라는 범위 내에서만 성실하게 공부했던 것 같다. 대신 내가 좋아했던 음악, 춤을 누가 시키지 않아도 공부하고 연구했었다. 오히려 전 회사에서 해답이 없는 문제를 많이 풀어봤던 것 같다. 하지만 회사에서 엔지니어로서는 경험과 "짬"으로 쌓여가는 노하우로 성장했다면, 지금은 찾아볼 수 있는 자료들을 공부하고 원리를 찾아가며 성장해 나아감을 느끼고, 이 과정이 너무나도 재밌다! 이 재미를 오랬동안 놓치지 않기 위해 부단히 노력해야지!
앞으로도 빠이팅이다! 🙌

[WebFlux] WebFlux 성능 하락 요인
본 글은 [NHN FORWARD 2020] 내가 만든 WebFlux가 느렸던 이유 영상을 보고 공부하며 정리한 내용입니다.
보다 subscribeOn(), publishOn()에 대해 추가로 공부한 블로그도 아래 참조에 적어두었습니다.
[NHN FORWARD 2020] 내가 만든 WebFlux가 느렸던 이유
https://www.youtube.com/watch?v=I0zMm6wIbRI&list=PLYrXsqzQqYouIRRx7CuF-KviN3rilLUoH&index=2
- Spring MVC
- Thread per Request Model
- Thread pool의 쓰레드 개수 == 200개 (SpringBoot의 기본 설정)
- thread 1 에서 REST API 호출 시 응답 서버에서 결과를 획득까지 block
- 응답이 되면 다시 thread 1 이 runnable
- 200개의 쓰레드들이 경합할 수 있음
- 머신 코어의 개수는 대부분 2 ~ 4개, 최대 8개
- WebFlux
- EventLoop Model
- EventQueue
- Thread 개수 == Core 개수 * 2
- task를 이벤트 단위로 관리하여 thread가 block 되지 않음
- Context Switching 오버헤드 감소, 코어 경합 강소
- Thread Pool의 thread 수가 적음으로 CPU 사용량이 높은 작업이 많거나 blocking I/O를 사용한다면 비효율 적일 수 있다
- 동영상 인코딩, 암호화 등
성능 하락 원인
- AsyncAppender?
- async blocking appender
- 로그를 잠시 보관했다가 비동기로 파일에 로그를 남기는 것
- 이러다 보니 asyncappender에 로그가 쌓여 JVM Hang 발생 가능 (Heap 영역 메모리 터짐)
- .log()
- blocking I/O 발생
- 성능 하락 가능
- map() vs flatMap()
- map() : 동기식 함수를 적용하여 Mono의 아이템을 변경
- flatMap() : 비동기적으로 Mono의 아이템 변경
- 너무 많은 map() 함수의 조합은 계속 새로운 객체를 만들어 GC에 부하
개선사항
- BlockHound 사용
- Blocking 코드를 찾아주는 라이브러리
- reactor-core 3.3.0 부터 내장
- testcase 대상 코드에서 block 메소드 잡아내기 좋음
- Lettuce 설정
- Connection validation 시, 동기적으로 동작
- Command 실행 마다 ping command 를 동기적으로 동작
- 성능 하락
- setValidateConnection(true)
- Avoiding Reactor Meltdown
- Event Loop 의 Thread들이 Blocking API 때문에 Reactor 시스템이 Hang 걸리는 현상
- Blocking API를 위한 별도의 Thread Pool로 격리
- 내부 서비스 로직이 빠르고 외부 의존성(웹서비스나 DB)이 더 느리거나 IO blocking이 발생할 가능성이 높다.
- 외부 의존성에서 데이터를 읽어오거나 쓰는 경우는 모두 별도의 스케줄링을 적용하는 것이 바람직
- 아니면 main 쓰레드가 block
- subscribeOn()
- 전체 메소드 체인을 별도의 Thread Pool에서 사용
- 호출되는 위치가 무관
- 하나의 쓰레드로 동작 ⇒ 동시성이 높지 않음
- 느린 publisher와 빠른 subscriber 로 구성된 chain에서 사용
- 외부 의존성으로부터 읽어오는 경우 외부 의존성이 데이터를 생성하는 속도에 맞춰서 내부 서비스 로직이 움직여야 하기 때문에 같은 스케줄링이 적용되어야 하나 main 쓰레드와는 별도의 스케줄링으로 관리되는 것이 좋음
- publishOn()
- 메소드 체인 가운데 바로 다음 체인(부터 끝까지)을 별도의 Thread Pool에서 사용
- 빠른 publisher와 느린 subscriber 로 구성된 chain에서 사용
- 외부 의존성에 쓰는 경우 외부 의존성에 가까운 subscriber 쪽 chain을 별도의 스케줄링으로 관리하도록 분리 필요
참고 블로그
'기타 개발 관련' 카테고리의 다른 글
MAC bash 터미널에서 git branch 표시되도록 설정하기 (2) | 2023.08.29 |
---|
[프로그래머스 Lv 4 / Java / 자바][카카오 인턴 2019] 호텔 방 배정
https://school.programmers.co.kr/learn/courses/30/lessons/64063
프로그래머스
코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.
programmers.co.kr
아이디어
맵 + DFS
1, 3, 4, 다음에 1이 나오면 2로가고, 또 1이 나오면 5로 가야한다
여기서 낸 아이디어는 이미 지정된 방을 또 요청하면 다른 방으로 보낼 때 어느 방으로 보내야 하는지 맵에 저장해두면 되지 않을까?
동시에 이를 탐색하면서 매번 갱신해두면 나중에 다시 탐색할 때 중간 과정을 많이 생략할 수 있지 않을까?
유니온 파인드의 파인드 알고리즘처럼 자신의 부모로 탐색해 올라가다가 부모가 지정되지 않으면 그 바로 다음 방을 부모로 지정하면 된다.
여기서 부모란 나를 다시 호출할 때 넘길 방 번호이다.
여기서 DFS를 탐색하면서 map에 매번 갱신해주면 주어진 시간복잡도안에 해결할 수 있다.
map.put(a,DFS(map.get(a), map));
풀이
import java.util.*;
class Solution {
public long[] solution(long k, long[] room_number) {
int N = room_number.length;
long[] answer = new long[N];
// key - value 를 유니온 파인드의 파인드 알고리즘의 자식 - 부모로 생각해보자
Map<Long, Long> map = new HashMap<>();
for (int i = 0; i < N; i++) {
answer[i] = DFS(room_number[i], map);
}
return answer;
}
static long DFS(long a, Map<Long, Long> map){
// 한번도 방문한 적이 없으면 다음 방을 자신의 부모로 설정
if (!map.containsKey(a)){
map.put(a, a+1);
return a;
}
// 부모가 지정되지 않을 때까지 부모로 탐색하면서 갱신 (path shortening)
map.put(a,DFS(map.get(a), map));
return map.get(a);
}
}
후기
문제는 꾸준히 풀었는데 플젝한다고 업로드에 소홀해진 나..
반성하고 다시 올립니다 레츠고!
질문 피드백은 언제나 환영입니다 :)
'문제풀이 > Programmers' 카테고리의 다른 글
[프로그래머스 Lv 3 / Java / 자바] [2020 카카오 인턴십] 보석 쇼핑 (1) | 2023.11.12 |
---|---|
[프로그래머스 Lv 3 / Java / 자바] [2019 카카오 겨울 인턴십] 불량 사용자 (0) | 2023.11.10 |
[프로그래머스 Lv3 / Java / 자바] [2019 카카오 겨울 인턴십] 징검다리 건너기 (2) | 2023.11.09 |
[프로그래머스 Lv2 / Java / 자바] [2019 카카오 겨울 인턴십] 튜플 (0) | 2023.11.08 |
[SpringBoot / Google Cloud Vision API] WebFlux 적용기
프로젝트 2개째 진행하느라 노션이랑 깃헙에만 열심히 기록하고 블로그 글을 한달 넘게 못썼다..
프로젝트는 정말 재밌고 빡센거구나
확실히 만들어봐야 많이 배우는 것 같다.
https://cloud.google.com/vision/docs/detecting-landmarks?hl=ko
특징 감지 | Cloud Vision API | Google Cloud
의견 보내기 특징 감지 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 명소 감지는 이미지에서 유명한 자연 경관과 인공 구조물을 감지하는 기능입니다. 참
cloud.google.com
필자는 CloudVision API를 사용하여 여러 이미지에 해당하는 키워드를 받아보는 기능을 작성중이었다.
Cloud Vision API는 구글의 Cloud Vision AI 를 사용하여 이미지의 특징(키워드), 주요 색상, 라벨 디텍팅 등의 기능을 사용할 수 있는 이미지 관련 기능을 제공한다.
API 문서의 Request 예시
{
"requests": [
{
"features": [
{
"maxResults": 10,
"type": "LANDMARK_DETECTION"
}
],
"image": {
"source": {
"imageUri": "gs://cloud-samples-data/vision/landmark/st_basils.jpeg"
}
}
}
]
}
Response
{
"responses": [
{
"landmarkAnnotations": [
{
"mid": "/m/0hm_7",
"description": "Red Square",
"score": 0.8098141,
"boundingPoly": {
"vertices": [
{},
{
"x": 2487
},
{
"x": 2487,
"y": 3213
},
{
"y": 3213
}
]
},
"locations": [
{
"latLng": {
"latitude": 55.753930299999993,
"longitude": 37.620794999999994
}
}
]
}
]
}
]
}
다른 블로그나 Cloud Vision API를 참고하여 한 개의 이미지를 제공하여 이에 해당하는 키워드를 받아오는 코드를 작성해보았다.
하지만 아래 코드를 보면 ImageRequest에 대해 하나하나 비동기적으로 작동한다. 때문에 API block이 많이 발생하여 매우 비효율적임을 알 수 있다. 내부 for문은 많은 키워드 중 정확도가 제일 높은 키워드 3개(MAX_KEYWORD_COUNT)까지만 사용하는 로직임으로 참고만 하자.
// 동기 코드 (비동기 전환 전)
@Autowired
private CloudVisionTemplate cloudVisionTemplate;
@Override
public List<PromptDTO> getKeywords(List<ImageRequest> imageRequests) {
List<PromptDTO> promptList = new ArrayList<>();
for (ImageRequest imageRequest : imageRequests) {
Resource imageResource = resourceLoader.getResource(imageRequest.getUrl());
AnnotateImageResponse res = this.cloudVisionTemplate.analyzeImage(imageResource,
Feature.Type.LABEL_DETECTION);
for (EntityAnnotation e : res.getLabelAnnotationsList().stream().
sorted(((o1, o2) -> (int) ((o2.getScore() - o1.getScore()) * 10000))). // 정확도가 높은 순으로
limit(MAX_KEYWORD_COUNT).toList()) { // MAX_KEYWORD_COUNT개 까지만 추출
promptList.add(new PromptDTO(e.getDescription(), e.getScore(), PromptDTOEnum.KEYWORD.getType()));
}
}
return promptList;
}
그러나 필자가 구현하기 위한 서비스에서는 이미지를 4개까지 넣어야 했고, 위 코드와 같이 for문을 돌면서 하나씩 api 요청을 보내는 것은 매우 비효율적이었다. 또한 추후에 이미지를 더 많이 사용하게 될 수도 있기 때문에 이를 비동기적으로 구현할 방법을 고민하다 Java Reactor를 구현한 WebFlux를 적용해보기로 했다. 이를 사용하면 이벤트, 데이터 스트림 비동기적으로 처리, 동시 수행 가능, 블로킹 피할 수 있어 성능 향상을 노릴 수 있다!
// 비동기 전환 코드
@Autowired
private CloudVisionTemplate cloudVisionTemplate;
@Override
public Flux<PromptDTO> getKeywords(List<ImageRequest> imageRequests) {
return Flux.fromIterable(imageRequests)
.flatMap(imageRequest -> {
Resource imageResource = resourceLoader.getResource(imageRequest.getUrl());
AnnotateImageResponse res = cloudVisionTemplate.analyzeImage(imageResource,
Feature.Type.LABEL_DETECTION);
return Flux.fromIterable(res.getLabelAnnotationsList())
.sort((o1, o2) -> (int) ((o2.getScore() - o1.getScore()) * 10000))
.take(MAX_KEYWORD_COUNT)
.map(e -> new PromptDTO(e.getDescription(), e.getScore(), PromptDTOEnum.KEYWORD.getType()));
});
}
우선, 2개 이상의 아이템을 방출하기 위해 Mono가 아닌 Flux를 사용하였고 fromIterable로 각 imageRequest에 대해 비동기적으로 API 요청을 보내도록 코드를 수정하였다.
여기서 필자가 익숙치 않았던 것은 Flux 안에서 새로운 Flux를 리턴하는 것이었는데 이렇게 활용할 수도 있음을 공부하면서 알게 되었다.
참고로 Flux 값 자체를 리턴하는 것은 이는 후에 구독자가 이를 subscibe하여 한번에 쌓인 이벤트를 처리할 수 있게 하기 위함이다.
이 코드에서 이벤트는 ImageRequest -> AnnotateImageResponse -> PromptDTO로 전환되면서 구독자에 의해 구독될 때까지 쌓이게 된다. 이후 아래 코드와 같이 컨트롤러 단에서 한번에 모인 PromptDTO를 구독하여 사용할 수 있다.
//MainController 활용
Map<String, List<PromptDTO>> promptDTOMap = new HashMap<>();
List<PromptDTO> keywords = new ArrayList<>();
//Flux를 subscribe하여 사용
cloudVisionService.getKeywords(imageRequests).subscribe((promptDTO -> keywords.add(promptDTO))); // 구독하여 리스트에 저장
promptDTOMap.put("keywords", keywords);
참고로 map 과 flatMap의 차이는 sync하냐 async하냐의 차이에 있다. 때문에 비동기한 처리를 위해서는 flatMap을 사용해야 하며, map 함수 메서드를 여러번 조합하게 되면 새로운 객체를 매번 생성하게 되어 GC에 부하를 줄 수 있음으로 주의해야 한다. 이에 관한 내용은 따로 NHN 클라우드 발표 영상 내용을 정리하면서 자세히 올리도록 하겠다.
그렇게 기분좋게 처리시간을 70% 가량 개선하였지만... 추출된 키워드만으로 조합된 프롬프트가 너무 조악하여 아예 GPT4의 신기술인 이미지를 input으로 주는 API를 사용하는 방향으로 전환한 것은 안비밀이다.. (위 코드는 바로 무쓸모됨 ㅎ)
하지만 Reactor를 사용해본 것은 기록할 만 하다고 생각되어.. 매우 정돈되지 않은 글을 써본다.
이후에는 아래의 저 괴상한 이미지를 어떻게 볼만하게 바꾸게 되었는지 작성해보도록 하겠다.
