유저 서버
유저 서버는 채팅 앱에서 친구 요청/수락/취소/삭제, 유저 검색, 프로필 수정의 기능을 수행한다.
유저 서버 주요 구현 내용
- 유저 쿼리
- 프로필 이미지 저장 - AWS S3
- 친구 상태 관리
유저 쿼리
유저 쿼리에서 유의미한 성능 향상과 좋은 팁들을 얻을 수 있었다.
우선 쿼리를 다시 점검하게 된 이유는 팀원의 Prometheus와 Grafana를 활용한 모니터링과 Ngrinder의 부하테스트 덕분이었다.
친구 목록 조회 쿼리가 채팅 앱의 거의 메인화면에 해당하기 때문에 가장 많이 사용되었다. 그러나 성능이 매우 뒤떨어 진다(TPS 20.8)는 결론을 얻을 수 있어서 성능 향상의 계기가 되었다.
성능 개선의 요인은 3가지가 있다.
- @Transactional(readOnly = true) 추가
- 쿼리 변경
- maxThread 와 maximumPoolsize 조정
@Transactional(readOnly = true) 의 경우 DB에 write를 하지 않기 때문에 성능에 이점을 갖는다.
쓰기를 수행하지 않기 때문에 여러 요청이 들어오더라도 Dirty Check를 위한 스냅샷 저장, 영속성 컨텍스트 플러시를 하지 않게 되고 이로 인해 속도가 빨라지게 된다.
쿼리 변경 또한 성능 향상에 주요했다.
주석 부분의 이전 코드의 경우 친구 리스트가 총 10명인 경우 10번의 쿼리를 수행하게 된다.
그러나 findByIdIn 형태를 사용함으로써 친구들을 한명씩 조회하지 않고 여러 명을 한번에 조회하는 형태로 바꾸었다.
@Transactional(readOnly = true)
public List<ResponseProfile> friendList(String jwt) {
JwtUser sender = jwtTokenProvider.getUserInfo(jwt);
UserEntity senderEntity = userRepository.findByEmail(sender.getEmail());
if (senderEntity == null)
throw new UserNotFoundException(String.format("[%s] is Not Found", sender.getEmail()));
List<FriendEntity> friendEntityList = friendRepository.findBySenderIdOrReceiverIdAndStatus(
senderEntity.getId(),
FriendshipCode.ACCCEPTED.value()
);
List<Long> userIdList = new ArrayList<>();
if (friendEntityList != null) {
for(FriendEntity friend : friendEntityList) {
if (friend.getSenderId().equals(senderEntity.getId())){
userIdList.add(friend.getReceiverId());
}else {
userIdList.add(friend.getSenderId());
}
}
}
List<UserEntity> userEntities = userRepository.findByIdIn(userIdList);
return convertToResponseProfile(userEntities);
// 이전 코드 ------------------------------------------------------------------------------------------
// List<ResponseProfile> responseFriendList = new ArrayList<>();
// if (friendEntityList != null) {
// for(FriendEntity friend : friendEntityList) {
// UserEntity user;
// if (friend.getSenderId().equals(senderEntity.getId())) { //내가 요청한 경우(sender) -> 수락한 사람이 친구 (receiver)
// user = userRepository.findById(friend.getReceiverId()).orElse(null);
// } else { // 내가 수락한 경우(receiver) -> 요청한 사람이 친구 (sender)
// user = userRepository.findById(friend.getSenderId()).orElse(null);
// }
// if (user == null)
// throw new UserNotFoundException("user is Not Found in friendship");
//
// ResponseProfile userProfile = new ResponseProfile(user.getUserId(), user.getEmail(), user.getProfile());
// responseFriendList.add(userProfile);
// }
// log.error(responseFriendList.toString());
// }
// return responseFriendList;
}
List<ResponseProfile> convertToResponseProfile(List<UserEntity> userEntities){
List<ResponseProfile> responseProfiles = new ArrayList<>();
userEntities.stream().map(ue-> responseProfiles.add(new ResponseProfile(ue.getUserId(), ue.getEmail(),ue.getProfile()))).collect(Collectors.toList());
return responseProfiles;
마지막으로 maxThreads와 maximumPoolsize이다.
maxThreads 란 최대 사용할 수 있는 스레드의 갯수이다.
이는 서버 성능이 좋을 수록 많은 쓰레드를 활용할 수 있기때문에 리소스에 따라 최적의 값을 구하면 더욱 좋은 성능을 낼 수 있다.
maximumPoolsize란 Connection Pool이 가질 수 있는 최대 커넥션 갯수이다.
일반적으로 Spring Boot는 hikariCP를 사용하고 DB 요청이 들어올 때마다 DB에 연결하는 것이 아닌, 설정에 따라 연결을 유지하고 있다가 요청 시 해당 connection pool을 활용하는 방식이다.
maximumPoolsize 또한 테스트를 통해 리소스에 따른 최적의 풀 갯수를 찾으면 성능을 향상 시킬 수 있다.
Thread를 무작정 늘린다 해도 서버 리소스가 받쳐주지 않으면 성능은 오히려 떨어질 수 있으며, 이를 반영하여 구한 최적의 갯수는 100개이다. 또한 maximu Poolsize를 20으로 하였을 때 성능이 우수함을 확인할 수 있었고 결과적으로 TPS가 20.8 -> 186.6으로 유의미한 성능 개선을 할 수 있었다.
프로필 이미지 저장 - AWS S3
유저 프로필 이미지를 저장하기 위해 AWS S3를 활용하였다.
AWS 키가 노출될 경우 Git Guardian에 의해 경고 메일을 받기 때문에 보안을 유지해야 한다.
@Configuration
public class AWSConfig {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey,secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
.build();
}
}
이미지의 경우 Multipartfile을 통해 S3에 이미지를 전송하였고, 정상적으로 저장 시 응답받은 이미지 Uri를 DB에 저장한다.
기본적으로 uri를 통해 관리하며 이 uri를 모바일에 보낼 경우 이미지를 다운받아 사용할 수 있다.
public ResponseProfile getProfile(String target) {
UserEntity userEntity = userRepository.findByUserIdOrEmail(target,target);
if (userEntity == null) {
throw new UserNotFoundException(String.format("[%s] is Not Found", target));
} else {
ResponseProfile responseProfile = new ResponseProfile(
userEntity.getUserId(), userEntity.getEmail(),userEntity.getProfile());
return responseProfile;
}
}
public ResponseProfile putProfile(RequestProfile profile) {
UserEntity userEntity = userRepository.findByUserIdOrEmail(profile.getTarget(),profile.getTarget());
if (userEntity == null) {
throw new UserNotFoundException(String.format("[%s] is Not Found",profile.getTarget()));
}
MultipartFile multipartFile = profile.getImg();
String s3FileName = UUID.randomUUID() + "-" + multipartFile.getOriginalFilename();
ObjectMetadata objMeta = new ObjectMetadata();
try {
objMeta.setContentLength(multipartFile.getInputStream().available());
amazonS3Client.putObject(S3Bucket, s3FileName, multipartFile.getInputStream(), objMeta);
String uri = amazonS3Client.getUrl(S3Bucket, s3FileName).toString();
userEntity.setProfile(Map.of(
"nickname", profile.getNickname(),
"img", uri
));
UserEntity savedUser = userRepository.save(userEntity);
ResponseProfile responseProfile = new ResponseProfile(
savedUser.getUserId(), savedUser.getEmail(), savedUser.getProfile()
);
return responseProfile;
} catch (IOException e) {
e.printStackTrace();
throw new FileStreamException("file streaming is failed");
}
}
친구 상태 관리
크게 5개로 상태를 나누었으며 이를 쿼리에도 활용한다.
public enum FriendshipCode {
NONE(0),
REQUESTED(1),
ACCCEPTED(2),
REJECTED(3),
BLOCKED(9),
;
private final int status;
FriendshipCode(int status) {
this.status = status;
}
public int value() {
return this.status;
}
}
푸시 서버
푸시 서버 주요 구현 내용
- fcm 토큰 등록
- 푸시 알림 전송 - FCM
fcm 토큰 등록
fcm 토큰의 경우 DB에 저장하였다.
그 이유는 임시 동안 존재하는 데이터가 아니며 앱을 사용하면 계속해서 푸시 알림을 전송받아야하기 때문이다.
그러나 다른 디바이스를 사용하게 되는 경우 fcm 토큰을 변경해야하기 때문에 fcm 토큰을 등록하기 위한 API를 구현하였다.
푸시 알림 전송 - FCM
한명에게 전달하는 경우(1:1 채팅) to, 다수에게 전달하는 경우(그룹 채팅) registration_ids를 사용할 수 있다.
그러나 초기 기능 구상과 달리 message 배열을 만들고 이를 한번에 일괄 전송하는 식으로 구현하였다.
정상적으로 메시지가 보내지는 것을 확인할 수 있다.
그러나 푸시 알림에 많은 요청이 들어오는 경우 비동기 처리가 필요할 것으로 생각된다. 그러나 이를 구현하지 못해 아쉽다.
혹은 메시지 큐를 통해 메시지 큐에 담았다가 하나씩 요청을 꺼내며 처리할 수 있을텐데 이를 구현하지 못했다.
public void sendByTokenList(RequestMessage message) {
List<String> tokenList = new ArrayList<>();
message.getTarget().forEach(target -> {
UserEntity userEntity = userRepository.findByUserIdOrEmail(target,target);
String token = userEntity.getFcmToken();
log.error(userEntity.getUserId() + " : " +token);
tokenList.add(token);
});
List<Message> messages;
try {
messages = tokenList.stream().map(token-> Message.builder()
.putData("time", LocalDateTime.now().toString())
.putData("title", message.getTitle())
.putData("body", message.getBody())
.putData("roomId", message.getRoomId())
.setNotification(new Notification(message.getTitle(),message.getBody()))
.setToken(token)
.build()).collect(Collectors.toList());
} catch (NullPointerException e) {
e.printStackTrace();
throw new PushFormatException("요청 데이터가 유효하지 않습니다.");
}
// MulticastMessage.builder().addAllTokens(tokenList).putData()
BatchResponse response;
try {
//알림 발송
response = FirebaseMessaging.getInstance().sendAll(messages);
if (response.getFailureCount() > 0) {
List<SendResponse> responses = response.getResponses();
List<String> failedTokens = new ArrayList<>();
for (int i = 0; i< responses.size(); i++) {
if (!responses.get(i).isSuccessful()) {
failedTokens.add(tokenList.get(i));
}
log.error(responses.get(i).getMessageId());
}
if (!failedTokens.isEmpty()) {
log.error("List of tokens are not valid FCM token : " + failedTokens);
throw new PushException("List of tokens are not valid FCM token : " + failedTokens);
}
}
} catch (FirebaseMessagingException e ) {
log.error("can not send to memberList push message. error info : {}", e.getMessage());
throw new PushException("can not send to memberList push message. error info : " + e.getMessage());
}
}
'프로젝트 > 스마일게이트 윈터데브캠프' 카테고리의 다른 글
[윈터데브캠프] 팀프로젝트 - 인증 (게이트웨이, 인증 서버) (0) | 2023.04.03 |
---|---|
[윈터데브캠프] 팀프로젝트 - API,DB (+요청/응답, 포트) (0) | 2023.04.03 |
[윈터데브캠프] 팀프로젝트 - 아키텍처 (0) | 2023.03.31 |
1인 프로젝트 - MSA 기반 인증서버 개발 (0) | 2023.02.19 |