04-11 03:22
Notice
Recent Posts
관리 메뉴

독산구너

데이터 중심 설계에서 책임 주도 설계로 ('오브젝트'를 읽고) -1 기존 프로젝트 문제점 발견 본문

프로젝트/[SW마에스트로] SROOM

데이터 중심 설계에서 책임 주도 설계로 ('오브젝트'를 읽고) -1 기존 프로젝트 문제점 발견

독산구너 2023. 10. 15. 22:01

이 글의 목적

- '스룸' 프로젝트의 BE 구조 리팩토링의 필요성을 설명하고자합니다.

 

리팩토링을 하게 된 과정

'스룸' 프로젝트를 진행하는 '4m9d' 팀의 SW마에스트로 백엔드 담당 멘토님께 BE repo 코드 피드백을 받았습니다. 내용은 다음과 같습니다.

- service에서 하는 일이 너무 많다.

- 의존관계가 복잡하다. 이상하다.

- private과 public 인 경우를 구별해라

- 자바 공부를 해라

 

이러한 피드백을 자바 스프링을 사용하여 개발을 하는 과정에서 '객체지향적인 코드를 작성하지 않았다'라는 의미로 받아들였고, 조영호 저자인 '코드로 이해하는 객체지향 설계, 오브젝트' 를 읽고 이를 프로젝트에 적용하여 리팩토링 하게 되었습니다.

 

'오브젝트' 의 내용

간략하게 책 '오브젝트' 의 내용을 이해한 대로 정리해보고자 합니다.

 

1. 객체 내부의 세부적인 사항을 감추는 '캡슐화(encapsulation)' 가 필요하다. 캡슐화를 통해 변경하기 쉬운 객체를 만들 수 있고, 객체간 결합도를 낮출 수 있어 설계가 바뀌었을 때 쉽게 코드를 변경할 수 있다.

 

2. 자신의 데이터를 스스로 처리하고, 연관성 없는 작업은 다른 객체에게 부여하는 객체는 '응집도(cohension)'가 높다고 말하며, 이를 통해 객체간의 결합도도 낮출 수 있다.

 

3. 프로세스와 데이터를 별도에 위치시키는 방식을 '절차적 프로그래밍' 이라고 한다. 이와 다르게 '객체지향 프로그래밍'은 데이터와 프로세스가 동일한 모듈 내부에 위치하여 객체가 데이터를 사용해 역할을 수행할 수 있도록 한다.

 

4. 객체에게 적절한 '책임'을 부여하고, 책임을 수행하는 객체간의 '협력'을 통해 공동체가 이루어진다. 객체는 협력자이다.

 

5. 객체가 어떤 정보는 노출하고, 어떤 정보는 숨길지 결정하는 것이 중요하다. 이를통해 객체에게 자율성을 부여할 수 있다.

 

6. 객체에게 맡겨진 책임은 객체에게 메시지를 전달함으로서 수행된다. (이는 메서드와 다르다.)

 

7. 구현과 관된 모든 것들이 '트레이드 오프' 대상이 될 수 있다. 아주 사소한 결정이라도 트레이드 오프를 통해 합당한 이유를 찾아 코드를 작성해야 한다.

 

8. 객체를 정하고 객체의 역할, 책임, 메시지를 정하는 것이 아니라 행동, 메시지, 책임을 통해 객체를 생성해야 한다.

 

9. 데이터 중심 설계는 객체의 행동보가 상태에 초점을 맞춘다. 이는 너무 이른 시기에 데이터에 대해 고민하게 되기 때문에 캡슐화에 실패하게 된다.

- 데이터 중심 설계는 객체를 고립시킨 채, 오퍼레이션을 정의하도록 한다. (8번이 불가능하게 된다)

 

10. 올바른 객체지향 설계는 클라이언트에게 전송할 메시지를 결정한 후에야 비로소 객체의 상태를 저장하는데 필요한 내부 데이터에 관해 고민하기 시작하는 것이다.

 

11. 묻지 말고 시켜라. 객체의 외부에서 해당 객체의 상태를 기반으로 결정을 내리는 것은 캡슐화를 위반한다.

 

12. 명령(프로시저)과 쿼리(함수)를 분리해라. 명령은 데이터를 수정하는 것이고, 쿼리는 데이터를 불러오는 것이다. 쿼리을 내렸는데 데이터가 수정되면 안되고, 명령을 실행했는데 데이터가 넘어오면 안된다.

 

여기까지가 책 '오브젝트' 를 약 200 페이지까지 읽고 정리한 핵심 내용입니다.

 

무엇이 잘못되었는가?

스룸 프로젝트의 BE 구조는 위의 핵심 내용의 거의 전부를 위반하고 있습니다.

 

먼저, 기존 프로젝트 코드 일부를 보겠습니다.

이 CourseService 객체에서는 사용자가 영상 또는 플레이리스트를 코스에 등록하는 로직을 수행합니다.

@Service
public class CourseService {

    private final VideoRepository videoRepository;
    private final CourseRepository courseRepository;
    private final CourseVideoRepository courseVideoRepository;
    private final CourseQuizRepository courseQuizRepository;
    private final LectureRepository lectureRepository;
    private final PlaylistRepository playlistRepository;
    private final PlaylistVideoRepository playlistVideoRepository;
    private final DateUtil dateUtil;
    private final YoutubeApi youtubeApi;
    private final YoutubeUtil youtubeUtil;
    private final GPTService gptService;

    public CourseService(VideoRepository videoRepository, CourseRepository courseRepository,
                         CourseVideoRepository courseVideoRepository, CourseQuizRepository courseQuizRepository, LectureRepository lectureRepository,
                         PlaylistRepository playlistRepository, PlaylistVideoRepository playlistVideoRepository,
                         DateUtil dateUtil, YoutubeApi youtubeApi, YoutubeUtil youtubeUtil, GPTService gptService) {
        this.videoRepository = videoRepository;
        this.courseRepository = courseRepository;
        this.courseVideoRepository = courseVideoRepository;
        this.courseQuizRepository = courseQuizRepository;
        this.lectureRepository = lectureRepository;
        this.playlistRepository = playlistRepository;
        this.playlistVideoRepository = playlistVideoRepository;
        this.dateUtil = dateUtil;
        this.youtubeApi = youtubeApi;
        this.youtubeUtil = youtubeUtil;
        this.gptService = gptService;
    }

    public void requestToFastApi(Video video) {
        log.info("request to AI server successfully. videoCode = {}, title = {}", video.getVideoCode(), video.getTitle());

        if (video.getMaterialStatus() == null || video.getMaterialStatus() == MaterialStatus.NO_REQUEST.getValue()) {
            gptService.requestToFastApi(video.getVideoCode(), video.getTitle());
            video.setMaterialStatus(MaterialStatus.CREATING.getValue());
            videoRepository.updateById(video.getVideoId(), video);
        }
    }

    @Transactional
    public EnrolledCourseInfo saveCourseWithPlaylist(Long memberId, NewLecture newLecture, boolean useSchedule, Playlist playlist) {
        log.info("course inserted. member = {}, lectureCode = {}", memberId, newLecture.getLectureCode());
        Course course = saveCourse(memberId, newLecture, useSchedule, playlist);

        Lecture lecture = lectureRepository.save(Lecture.builder()
                .memberId(memberId)
                .courseId(course.getCourseId())
                .sourceId(playlist.getPlaylistId())
                .channel(playlist.getChannel())
                .playlist(true)
                .lectureIndex(ENROLL_LECTURE_INDEX)
                .build());

        saveScheduledVideoListForCourse(memberId, course.getCourseId(), lecture.getId(), newLecture, playlist.getPlaylistId(), useSchedule);

        return EnrolledCourseInfo.builder()
                .title(playlist.getTitle())
                .courseId(course.getCourseId())
                .lectureId(lecture.getId())
                .build();
    }

    private Course saveCourse(Long memberId, NewLecture newLecture, boolean useSchedule, Playlist playlist) {
        Integer weeks = null;
        Integer dailyTargetTime = null;
        Date expectedEndDate = null;

        if (useSchedule) {
            validateScheduleField(newLecture);
            expectedEndDate = dateUtil.convertStringToDate(newLecture.getExpectedEndDate());
            weeks = newLecture.getScheduling().size();
            dailyTargetTime = newLecture.getDailyTargetTime();
        }

        return courseRepository.save(Course.builder()
                .memberId(memberId)
                .courseTitle(playlist.getTitle())
                .weeks(weeks)
                .scheduled(useSchedule)
                .duration(playlist.getDuration())
                .thumbnail(playlist.getThumbnail())
                .dailyTargetTime(dailyTargetTime)
                .expectedEndDate(expectedEndDate)
                .build());
    }

    private void saveScheduledVideoListForCourse(Long memberId, Long courseId, Long lectureId, NewLecture newLecture, Long playlistId, boolean useSchedule) {
        int videoCount = 1;
        int week = 0;
        int section = ENROLL_DEFAULT_SECTION_NO_SCHEDULE;
        if (useSchedule) {
            section = ENROLL_DEFAULT_SECTION_SCHEDULE;
        }

        int videoIndex = 1;
        for (Video video : videoRepository.getListByPlaylistId(playlistId)) {
            if (useSchedule && videoCount > newLecture.getScheduling().get(week)) {
                week++;
                section++;
                videoCount = 1;
            }
            courseVideoRepository.save(CourseVideo.builder()
                    .memberId(memberId)
                    .courseId(courseId)
                    .lectureId(lectureId)
                    .videoId(video.getVideoId())
                    .section(section)
                    .videoIndex(videoIndex++)
                    .lectureIndex(ENROLL_LECTURE_INDEX)
                    .summaryId(video.getSummaryId())
                    .build());
            videoCount++;
        }
    }

    @Transactional
    public EnrolledCourseInfo saveCourseWithVideo(Long memberId, NewLecture newLecture, boolean useSchedule) {
        log.info("course inserted. member = {}, lectureCode = {}", memberId, newLecture.getLectureCode());
        Video video = getVideoAndRequestToAiServer(newLecture.getLectureCode());

        Date expectedEndDate = null;
        Integer dailyTargetTime = null;
        Integer weeks = null;
        int section = ENROLL_DEFAULT_SECTION_NO_SCHEDULE;

        if (useSchedule) {
            validateScheduleField(newLecture);
            expectedEndDate = dateUtil.convertStringToDate(newLecture.getExpectedEndDate());
            dailyTargetTime = newLecture.getDailyTargetTime();
            weeks = newLecture.getScheduling().size();
            section = ENROLL_DEFAULT_SECTION_SCHEDULE;
        }

        Course course = courseRepository.save(Course.builder()
                .memberId(memberId)
                .courseTitle(video.getTitle())
                .duration(video.getDuration())
                .thumbnail(video.getThumbnail())
                .scheduled(useSchedule)
                .weeks(weeks)
                .expectedEndDate(expectedEndDate)
                .dailyTargetTime(dailyTargetTime)
                .build());

        Lecture lecture = lectureRepository.save(Lecture.builder()
                .memberId(memberId)
                .courseId(course.getCourseId())
                .sourceId(video.getVideoId())
                .channel(video.getChannel())
                .playlist(false)
                .lectureIndex(ENROLL_LECTURE_INDEX)
                .build());

        courseVideoRepository.save(CourseVideo.builder()
                .memberId(memberId)
                .courseId(course.getCourseId())
                .videoId(video.getVideoId())
                .section(section)
                .videoIndex(ENROLL_VIDEO_INDEX)
                .lectureIndex(ENROLL_LECTURE_INDEX)
                .lectureId(lecture.getId())
                .summaryId(video.getSummaryId())
                .build());

        return EnrolledCourseInfo.builder()
                .title(video.getTitle())
                .courseId(course.getCourseId())
                .lectureId(lecture.getId())
                .build();
    }

    private void validateScheduleField(NewLecture newLecture) {
        if (newLecture.getDailyTargetTime() == 0 ||
                newLecture.getExpectedEndDate() == null) {
            throw new InvalidParameterException("스케줄 필드를 적절히 입력해주세요");
        }
    }

    public Playlist getPlaylistWithUpdate(String playlistCode) {
        Optional<Playlist> playlistOptional = playlistRepository.findByCode(playlistCode);

        if (playlistOptional.isPresent() &&
                dateUtil.validateExpiration(playlistOptional.get().getUpdatedAt(), PLAYLIST_UPDATE_THRESHOLD_HOURS)) {
            return playlistOptional.get();
        } else {
            Playlist playlist = youtubeUtil.getPlaylistWithBlocking(playlistCode);
            if (playlistOptional.isEmpty()) {
                playlist = playlistRepository.save(playlist);
            } else {
                playlist.setAccumulatedRating(playlistOptional.get().getAccumulatedRating());
                playlist.setReviewCount(playlistOptional.get().getReviewCount());
                playlist = playlistRepository.updateById(playlistOptional.get().getPlaylistId(), playlist);
            }
            playlistVideoRepository.deleteByPlaylistId(playlist.getPlaylistId());
            playlist.setDuration(putPlaylistItemAndGetPlaylistDuration(playlist));
            return playlistRepository.updateById(playlist.getPlaylistId(), playlist);
        }

    }

    public int putPlaylistItemAndGetPlaylistDuration(Playlist playlist) {
        String nextPageToken = null;
        int playlistDuration = 0;
        int pageCount = (int) Math.ceil((double) playlist.getVideoCount() / DEFAULT_INDEX_COUNT);
        for (int i = 0; i < pageCount; i++) {
            PlaylistPageResult result = putPlaylistItemPerPage(playlist.getPlaylistCode(),
                    playlist.getPlaylistId(), nextPageToken);
            nextPageToken = result.getNextPageToken();
            playlistDuration += result.getTotalDurationPerPage();
        }
        return playlistDuration;
    }

    private PlaylistPageResult putPlaylistItemPerPage(String lectureCode, Long playlistId, String nextPageToken) {
        Mono<PlaylistVideoVo> playlistVideoVoMono = youtubeApi.getPlaylistVideoVo(PlaylistItemReq.builder()
                .playlistCode(lectureCode)
                .limit(DEFAULT_INDEX_COUNT)
                .nextPageToken(nextPageToken)
                .build());
        PlaylistVideoVo playlistVideoVo = youtubeUtil.safeGetVo(playlistVideoVoMono);

        List<Video> videoList = getVideoList(playlistVideoVo);
        int totalDurationPerPage = videoList.stream().mapToInt(Video::getDuration).sum();
        savePlaylistVideoList(playlistId, videoList, playlistVideoVo.getItems());

        return new PlaylistPageResult(playlistVideoVo.getNextPageToken(), totalDurationPerPage);
    }

    private List<Video> getVideoList(PlaylistVideoVo playlistVideoVo) {
        List<CompletableFuture<Video>> futureVideos = new ArrayList<>();
        for (PlaylistVideoItemVo itemVo : playlistVideoVo.getItems()) {
            if (youtubeUtil.isPrivacyStatusUnusable(itemVo)) {
                continue;
            }
            CompletableFuture<Video> videoFuture = getVideoWithUpdateAsync(itemVo.getSnippet().getResourceId().getVideoId())
                    .thenApply(video -> {
                        requestToFastApi(video);
                        return video;
                    });
            futureVideos.add(videoFuture);
        }
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(futureVideos.toArray(new CompletableFuture[0]));
        CompletableFuture<List<Video>> allVideoFuture = allFutures.thenApply(v ->
                futureVideos.stream()
                        .map(CompletableFuture::join)
                        .collect(Collectors.toList()));
        return allVideoFuture.join();
    }

    public void savePlaylistVideoList(Long playlistId, List<Video> videos, List<PlaylistVideoItemVo> items) {
        for (int i = 0; i < videos.size(); i++) {
            playlistVideoRepository.save(PlaylistVideo.builder()
                    .playlistId(playlistId)
                    .videoId(videos
                            .get(i)
                            .getVideoId())
                    .videoIndex(items
                            .get(i)
                            .getSnippet()
                            .getPosition() + 1)
                    .build());
        }
    }

 

이쯤에서 다시, 멘토님의 피드백을 보겠습니다.

 

1. service에서 하는 일이 너무 많다.

-> 이는 객체에게 적절한 책임이 나눠서 부여되지 않았다는 뜻이 됩니다. 위의 코드에서 Course 객체를 생성하는 것 이를 db 에 저장하는것, 사용자 설정대로 Schedule 하는것, 사용자가 선택한 Video 또는 Playlist를 youtube Api 인터페이스에게서 불러오는 것, 불러온 video 또는 playlist 를 db 에 저장하는것, 강의 자료를 생성하기 위해 AI 서버에게 요청하는 것 등 '강의 등록' 에 필요한 모든 책임이 CourseService에게 부과되어 있습니다.

 

이는, 이 CourseService가 연관성 없는 작업은 다른 객체에게 부여하는 '응집도(cohension)'가 낮다고 볼 수 있습니다.

또한 객체간의 협력이 없다고 볼 수 있습니다. 이는 다른말로 객체에게 적절한 책임을 부과하지 않았다라고 볼 수 있습니다.

 

2. 의존관계가 복잡하다. 이상하다.

-> 이건 보시다시피 복잡하네요. 이는 각 객체에게 적절한 책임을 부과하지 않고 CourseServicer가 모든 책임을 수행하게 되어 생기는 문제점입니다.

 

3. public과 private인 경우를 구별해라

-> 객체의 정보 노출을 최소화시켜야 합니다. 위의 코드는 객체들이 어떤 오퍼레이션을 제공하는지 알기 어렵습니다.

 

4. 자바 공부를 해라

-> 결론적으로 객체 지향적, 책임주도 설계가 아닌 절차지향적, 데이터 중심 설계가 이루어졌음을 알 수 있습니다.

 

 

그래서 뭐가 문제인가?

제 설계의 문제점은 다음과 같습니다.

- 책임주도 설계를 하지 않아 각 객체가 캡슐화가 되지 않아 하나의 객체 수정으로 코드 전체를 수정해야 하는 문제점이 발생합니다.

- 또한, 디버깅이 쉽지 않게 됩니다. 책임을 적절히 객체에게 부여하지 않았기 때문에, 위와같이 하나의 객체에서 심각하게 많은 책임을 갖게되고, 문제점을 발견하기 어렵게 됩니다.

- 데이터 중심 설계로 인해 객체의 상태에 집중하게 되었습니다. 이는 객체를 고립시켜 책임을 부과하는데 어려움을 주게 됩니다.

- 명령과 쿼리를 구분하지 않았습니다. 이는 정보를 불러오는데 객체의 상태가 바뀌거나 생성하게 되어 코드를 재사용할 때 혼란을 야기할 수 있습니다.

 

추가적인 문제점

- vo, entity, dto를 구분하지 않았다.

- 식별자(id) 없는 entity를 사용했다.

 

 

다음 글에서는

다음 글에서는 기존 프로젝트를 리팩토링 하는 과정을 보여주고자 합니다. 이는 객체 지향적 설계. 즉, 책임을 적절하게 부여받는 객체를 설계함으로부터 시작됩니다.