문제
이번 주차를 지나며 겪었던 문제가 무엇이었나요?
-> 이번 주차에서 제일 힘들었던 것은 클린 아키텍처를 적용해보는 것이었습니다.
시도
문제를 해결하기 위해 어떤 시도를 하셨나요?
-> 인프라 레이어가 도메인 레이어를 바라보도록 DIP를 적용시켰습니다.
해결
문제를 어떻게 해결하셨나요?
-> 먼저 패키지 구조를 3개로 나누었습니다.
이렇게 나눈 이유는 Domain Layer를 중심으로 레이어 계층간의 간섭이 이뤄줘야 클린 아키텍처가 적용이 되기 때문입니다. 또한 계층간에 간섭이 일어나지 않도록 Presentation 레이어와 Domain 레이어에 각각 DTO를 만들어서 하위모듈이 상위모듈을 참조하지 않게 만들었습니다.
- LectureService
package com.hhplus.clean.lecture.domain.service;
import com.hhplus.clean.lecture.domain.entity.Lecture;
import com.hhplus.clean.lecture.domain.entity.LectureHistory;
import com.hhplus.clean.lecture.domain.repository.LectureHistoryRepository;
import com.hhplus.clean.lecture.domain.repository.LectureRepository;
import com.hhplus.clean.lecture.domain.service.dto.*;
import com.hhplus.clean.lecture.exception.LectureException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static com.hhplus.clean.lecture.exception.ErrorCode.*;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LectureService {
private final LectureRepository lectureRepository;
private final LectureHistoryRepository lectureHistoryRepository;
// 특강 목록 조회
public List<LectureResponse> getLectures() {
return lectureRepository.findAll()
.stream()
.map(LectureResponse::of)
.toList();
}
// 특강 등록
@Transactional
public LectureResponse createLecture(LectureCreateServiceRequest request) {
// 같은 이름의 강의가 존재하는지 확인
validateDuplicateLectureName(request.name());
Lecture newLecture = lectureRepository.save(request.toEntity());
return LectureResponse.of(newLecture);
}
// 특강 삭제
@Transactional
public void deleteLecture(Long lectureId) {
getLectureOrThrow(lectureId);
lectureRepository.deleteById(lectureId);
}
// 특강 신청
@Transactional
public HistoryResponse applyLecture(LectureApplyServiceRequest request) {
// 강의가 있는지 찾는다.
Lecture lecture = getLectureOrThrow(request.lectureId());
// 강의가 시작 전인지, 강의가 정원이 꽉 차지 않았는지 확인한다. (validation) + 강의의 정원 count 를 하나 늘린다.
lecture.apply();
// 유저가 강의를 신청했는지 확인한다. (validation) + 강의 신청 내역에 저장한다.
validateDuplicateLectureHistory(request.lectureId(), request.userId());
// 유저 강의 신청 내역 저장
LectureHistory newHistory = lectureHistoryRepository.save(request.toEntity(lecture));
lecture.addLectureHistory(newHistory);
return HistoryResponse.of(newHistory);
}
// 특강 신청 여부 확인
public boolean checkHistories(Long lectureId, Long userId) {
return lectureHistoryRepository.existsByLectureIdAndUserId(lectureId, userId);
}
// 특강 신청 취소
@Transactional
public void cancelHistory(LectureCancelServiceRequest request) {
lectureHistoryRepository.deleteByLectureIdAndUserId(request.lectureId(), request.userId());
}
// 이미 강의 신청한 내역이 있는지 확인
private void validateDuplicateLectureHistory(Long lectureId, Long userId) {
if (lectureHistoryRepository.existsByLectureIdAndUserId(lectureId, userId)) {
throw new LectureException(DUPLICATED_LECTURE_APPLY, "이미 같은 특강에 등록한 유저가 존재합니다. '신청 특강 ID:%s, 신청 유저 ID:%s'"
.formatted(lectureId, userId));
}
}
// 이미 같은 이름의 강의가 있는지 확인
private void validateDuplicateLectureName(String name) {
if (lectureRepository.existsByName(name)) {
throw new LectureException(DUPLICATED_LECTURE_NAME, "이미 같은 이름의 강의가 존재합니다. '%s'".formatted(name));
}
}
private Lecture getLectureOrThrow(Long lectureId) {
return lectureRepository.findById(lectureId)
.orElseThrow(() -> new LectureException(LECTURE_NOT_EXIST, "특강이 존재하지 않습니다. '특강 ID:%s'".formatted(lectureId)));
}
}
- LectureRepository
package com.hhplus.clean.lecture.domain.repository;
import com.hhplus.clean.lecture.domain.entity.Lecture;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface LectureRepository {
Optional<Lecture> findById(Long lectureId);
Lecture save(Lecture lecture);
List<Lecture> findAll();
boolean existsByName(String name);
void deleteById(Long lectureId);
void deleteAllInBatch();
}
다음과 같이 Service 계층에서는 인터페이스 레포지터리를 빈 주입 받아 사용하게 됩니다.
여기에 대한 구현체는 Infra 레이어의 LectureRepositoryImpl 에서 구현을 하고 있으며, 이 안에서 JpaRepository의 빈을 주입받아 구현을 하고 있습니다.
- LectureRepositoryImpl
package com.hhplus.clean.lecture.infra;
import com.hhplus.clean.lecture.domain.entity.Lecture;
import com.hhplus.clean.lecture.domain.repository.LectureRepository;
import com.hhplus.clean.lecture.exception.ErrorCode;
import com.hhplus.clean.lecture.exception.LectureException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class LectureRepositoryImpl implements LectureRepository {
private final LectureJpaRepository lectureJpaRepository;
@Override
public Optional<Lecture> findById(Long lectureId) {
// return lectureJpaRepository.findById(lectureId);
return lectureJpaRepository.findIdWithPessimisticLock(lectureId); //동시성 제어를 위해 DB락 사용
}
@Override
public Lecture save(Lecture lecture) {
return lectureJpaRepository.save(lecture);
}
@Override
public List<Lecture> findAll() {
return lectureJpaRepository.findAllWithFetchJoin();
}
@Override
public boolean existsByName(String name) {
return lectureJpaRepository.existsByName(name);
}
@Override
public void deleteById(Long lectureId) {
Optional<Lecture> lecture = lectureJpaRepository.findById(lectureId);
if (lecture.isEmpty()) throw new LectureException(ErrorCode.LECTURE_NOT_EXIST, "삭제할 특강이 존재하지 않습니다.");
lectureJpaRepository.deleteById(lectureId);
}
@Override
public void deleteAllInBatch() {
lectureJpaRepository.deleteAllInBatch();
}
}
이렇게 하면 DIP는 물론 OCP도 지켜지게 됩니다.
알게된 것
문제를 해결하기 위해 시도하며 새롭게 알게된 것은 무엇인가요?
-> 레이어를 어느정도까지 추상화를 할 수 있는지 알게 되었고, 중간에 JPA가 아닌 mybatis나 JDBC Template으로 구현체를 바꾸더라도 도메인 레이어의 서비스를 바꾸지 않아도 되니 레이어간의 간섭이 일어나지 않습니다.
지난 목표 회고
지난 주에 설정해두었던 목표는 달성하셨나요? 잘된 것은 무엇이고 안된 것은 무엇인가요?
-> 네 기한 안에 과제 제출을 하여 목표를 달성하였습니다. 하면서 잘 안됐던 것은 처음 클린 아키텍처를 적용해 보았기 때문에 구조를 잡는데 많은 시간을 할애했습니다. 동시성 이슈는 DB Lock을 이용해서 구현하였지만, 락을 너무 오래 잡고 있을 때 타임아웃이라던지 그런 것까지 세세한 테스트를 하지 못해 아쉬웠습니다.
다음 목표 설정
반복적인 성장을 위한 실천 가능한 단기적인 목표를 설정해보세요!
-> 이제부터 본격적인 서버 구축을 시작으로 대장정의 프로젝트가 시작됩니다. 초석을 잘 다져서 멋진 엔지니어로 거듭나도록 노력하겠습니다.
'세션 > 항해플러스' 카테고리의 다른 글
항해 플러스 백엔드 챕터 2 후기 (0) | 2024.07.20 |
---|---|
WIL 3주차 회고 (0) | 2024.07.06 |
WIL 1주차 회고 (0) | 2024.06.22 |
99클럽 코테 스터디 28일차 TIL, 그리디(Iterator for Combination) (0) | 2024.06.18 |
시작하는 마음 (0) | 2024.06.15 |