1486 단어
7 분
SNS 게시글 피드 등록 전략

개요#

저번 포스팅에서는 이미지를 클라우드 스토리지(oci bucket)에 업로드, 동기화 전략을 세웠다. 이제는 피드 등록 api를 만드면서 여러 이미지 처리와 함께 게시글 등록 로직을 구현하려 한다. 요구사항과 예외처리를 고려해가며 설계, 구현해보자.

기능 요구사항#

sns 피드 등록을 위한 기능을 구현해야 한다.

앱에서 sns 피드를 등록하기 위해서는 이미지와 글 내용이 함께 들어가야 한다. 이미지는 최대 10장으로 제한하고, 피드를 게시하기 전 이미지를 미리 업로드한다. 그 이후 글을 작성하고, 최종으로 등록하려 할 때 버퍼 버킷에 있는 이미지들을 모두 디스크 버킷으로 옮기고 버퍼 버킷에 있는 이미지들은 삭제한다.

현재 피드부분 엔티티 설계는 다음과 같이 구현되어 있다.(편의상 데이터베이스의 타입으로 지정되어 있지 않음. 추후 변경 예정임)

image

피드를 등록할 때, 위치와 미디어(이미지)는 선택적으로 등록될 수 있다. 다시 말해, 필수적이지 않기 때문에 없는 경우도 고려해야 한다.

기능 flow#

요구사항이 생각보다 복잡하기 때문에, 간단한 추상화를 통해 기능이 어떻게 동작해야 할지 flow 이해를 해보자. mermaid로 flow를 정리해보았다.

image

피드를 등록하기 전, 이미지를 먼저 업로드해야 한다. 이 이미지 주소는 서버에서 저장하지 않는다. 클라이언트에서 가지고 있다가, 게시글을 등록할 때 이미지 등록을 완료한다.(이미지는 버퍼버킷에서 디스크 버킷으로 복사한다.) 그리고 위치가 있다면 위치도 추가로 등록하고, 없다면 피드 객체와 피드 미디어 객체만 등록을 완료한다.

흐름상 기능은 이해가 되었다. 이제 기능 목록을 작성해보자.

기능 목록#

  • 버퍼 버킷에 올린 이미지 내용은 데이터베이스에 저장되지 않으므로, 이미지 이름을 식별자와 함께 쉽게 구분될 수 있게 하고, 하나씩 올릴때마다 로그를 찍는다.(로그는 몽고DB에 저장되고 있음)
  • 버퍼 버킷에 여러 이미지를 업로드하고, 업로드한 결과를 반환한다.
  • 피드를 먼저 등록하고, 등록한 피드의 이미지들을 디스크 버킷으로 복사하고 바뀐 url을 반환한다.
  • 바뀐 url을 피드 미디어 객체에 저장하고, JPA를 사용하여 데이터베이스에 반영되게 한다.
  • 만약 위치 정보가 존재한다면, 위치 정보도 함께 데이터베이스에 저장한다.

추가 고려 사항#

  • 이미지 데이터 자체에 인덱스도 있어야 한다.

sns 피드의 경우, 이미지의 순서가 지켜져야 한다. 그래서 데이터상으로도, 이미지의 순서가 있어야 한다. 하지만 현재 Stream에서는 병렬적으로 처리가 되고 있다. 병렬처리시에는 각 스레드를 독립적으로 할당하여 처리를 하는 것으로 알고 있기 때문에, 인덱스를 증가시키는 방법으로는 불가능하다. 그래서 고민하다, 순서를 주입하기보다 클라이언트에서 게시글 업로드 요청을 할 때 순서를 받고 처리하는 방법으로 결정하였다.

구현#

  • 위 체크리스트에 해당하는 기능의 부분에 대한 의존성과 로직만 코드로 첨부하였다. 실제 비즈니스 로직은 서비스 계층에 존재하여 서비스 계층의 코드만 가져왔다.
@Service
@RequiredArgsConstructor  
public class FeedService {
	private final FeedRepository feedRepository;  
	private final FeedMediaRepository feedMediaRepository;  
	private final MemberRepository memberRepository;  
	private final LocationRepository locationRepository;  
	private final ObjectStorageService objectStorageService;  
	private final MemberService memberService;

	@Transactional
	public FeedPostResponse registerFeed(List<FileMetadataDto> metadataList, FeedRequest feedRequest) {  
	    MemberDto memberDto = memberService.getMember();  
	    Member member = memberRepository.findById(memberDto.getId()).orElseThrow();  
	  
	    // save feed entity  
	    Feed feed = Feed.of(member, feedRequest.getContent(),feedRequest.getCategory(), LocalDateTime.now(), LocalDateTime.now());  
	    feedRepository.save(feed);
	  
	    // finalizing feedImages: move buffer bucket images to disk bucket  
	    List<FeedTempDto> finalizedFeed = metadataList.parallelStream()  
	            .map(metadata -> {
	                try {  
	                    String finalFileName = String.format("%s/%s/%s?%s&%s",  
	                            member.getId(),  
	                            "feed",  
	                            feed.getId(),  
	                            "width=" + metadata.getWidth() + "&height=" + metadata.getHeight(),  
	                            "index=" + metadata.getIndex());
	                    String diskUrl = objectStorageService.copyToDisk(metadata,finalFileName);  
	  
	                    return FeedTempDto.builder()  
	                            .url(diskUrl)  
	                            .index(metadata.getIndex())  
	                            .build();  
	                } catch (BmcException e) {  
	                    logger.error("Failed to copy file: {}", metadata.getOriginalFileName(), e);  
	                    throw new RuntimeException("Failed to copy file: " + metadata.getOriginalFileName(), e);  
	                }  
	            })  
	            .toList();  
	  
	    finalizedFeed.forEach(feedTempData -> {  
	        FeedMedia feedMedia = FeedMedia.of(feed, "IMAGE", feedTempData.getUrl(),feedTempData.getIndex(), LocalDateTime.now(), LocalDateTime.now());  
	        feedMediaRepository.save(feedMedia);  
	    });  
	  
	    // save location entity if exist  
	    LocationRequest locationRequest = feedRequest.getLocation();  
	  
	    if(locationRequest.getAddress()!= null) {
	        Location location = Location.of(feed, locationRequest.getCoordinate(), locationRequest.getAddress(), locationRequest.getName(), LocalDateTime.now(), LocalDateTime.now());  
	        locationRepository.save(location);  
	    }  
	  
	    return FeedPostResponse.builder()  
	            .id(feed.getId().toString())  
	            .feedTempDtos(finalizedFeed)  
	            .content(feed.getContent()).build();  
	}
}

로직은 다음과 같다.

  • 현재 멤버(사용자) 정보를 가져옴
MemberDto memberDto = memberService.getMember();  
Member member = memberRepository.findById(memberDto.getId()).orElseThrow();
  • 피드(게시물) 기본 정보 저장
Feed feed = Feed.of(member, feedRequest.getContent(),feedRequest.getCategory(), LocalDateTime.now(), LocalDateTime.now());  
feedRepository.save(feed);
  • 이미지 파일 처리
List<FeedTempDto> finalizedFeed = metadataList.parallelStream()  
	            .map(metadata -> {
	                try {  
	                    String finalFileName = String.format("%s/%s/%s?%s&%s",  
	                            member.getId(),  
	                            "feed",  
	                            feed.getId(),  
	                            "width=" + metadata.getWidth() + "&height=" + metadata.getHeight(),  
	                            "index=" + metadata.getIndex());
	                    String diskUrl = objectStorageService.copyToDisk(metadata,finalFileName);  
	  
	                    return FeedTempDto.builder()  
	                            .url(diskUrl)  
	                            .index(metadata.getIndex())  
	                            .build();  
	                } catch (BmcException e) {  
	                    logger.error("Failed to copy file: {}", metadata.getOriginalFileName(), e);  
	                    throw new RuntimeException("Failed to copy file: " + metadata.getOriginalFileName(), e);  
	                }  
	            })  
	            .toList();  

아까 병렬 이슈로, 인덱스를 증가시키는 방법이 아닌, 클라이언트에서 직접 순서를 받는 방식으로 구성하였다.

  • 피드 미디어 정보 저장
finalizedFeed.forEach(feedTempData -> {  
	        FeedMedia feedMedia = FeedMedia.of(feed, "IMAGE", feedTempData.getUrl(),feedTempData.getIndex(), LocalDateTime.now(), LocalDateTime.now());  
	        feedMediaRepository.save(feedMedia);  
	    });
  • 위치 정보 처리 (선택적)
if(locationRequest.getAddress()!= null) {
	Location location = Location.of(feed, locationRequest.getCoordinate(), locationRequest.getAddress(), locationRequest.getName(), LocalDateTime.now(), LocalDateTime.now());  
	
	locationRepository.save(location);  
}
  • 최종 응답 반환
return FeedPostResponse.builder()  
	            .id(feed.getId().toString())  
	            .feedTempDtos(finalizedFeed)  
	            .content(feed.getContent()).build();  

리팩토링 해야할 부분이 너무 많아 보이지만, 일단 테스트 완료하고 이후 다른 기능들을 완성한 후에 리팩토링 하려 한다.

SNS 게시글 피드 등록 전략
https://blog-full-of-desire-v3.vercel.app/posts/resona/sns-registration-strategy/
저자
SpeculatingWook
게시일
2024-12-19
라이선스
CC BY-NC-SA 4.0