[LMS] 10일 동안의 '학습 관리 시스템' 개발 그리고 다른 팀과의 협업 이야기(1)
728x90

DEEP DIVE Spring 기반 백엔드 개발자 성장 과정 2차 프로젝트를 진행하였다.
2주 정도의 기간 동안 5명의 팀원이 모여서 팀 프로젝트를 진행하고, 우리 팀 외의 다른 팀을 고객 팀으로 선정하여 요구사항을 받아서 소통하면서 개발도 진행하였다.
우리는 4팀 GoGet고객 팀이다.
WHY GoGet고객?
우리 팀 요구사항에 잘 대응하고 직접 다른 팀과 소통하여 프로젝트를 진행하기 때문에 우리 자신도 고객, 다른 팀도 고객이라고 생각하고 진행해야 하는 프로젝트라고 생각했다. 그래서 고객을 위해서 Go 하고 고객의 마음을 Get 하자는 목표로 이렇게 팀명을 정하게 되었다!
빠른 시간 안에 결과물을 만들어야겠다는 생각이 우선되어 팀원들과 상의해 모든 팀원들이 대학 시절 경험해 보고 익숙한 시스템인 "학습 관리 시스템(LMS)" 으로 주제를 정하였다.

본 프로젝트는 학생과 강사를 위한 학습 관리 플랫폼으로, 강의 등록, 퀴즈 및 과제 관리, 성적 평가 등 효율적인 학습 진행 및 평가를 지원합니다. 학생들은 강의를 수강하고 퀴즈와 과제를 제출할 수 있으며, 강사는 강의, 학습 자료를 업로드하고 과제 및 퀴즈 점수를 관리할 수 있습니다.

 
이 프로젝트를 통하여 아래 사항들을 경험할 수 있을 것 같아서 선정하게 되었다.

  • 인증/인가
  • DB 접근
  • CI/CD
  • REST API 개발
  • git, github 등을 통한 협업 능력 향상

 

기술 스택

#Java 17  #Spring Boot 3   #Spring Security  #JWT #MySQL  #Redis  #JPA  #JUnit5  #Swagger  #AWS-EC2, RDS, S3  #Github Actions  #Docker
 
일정은 아래와 같다.

Day1 : 요구사항 정의 및 고객팀 요구사항 미팅
Day2 : 시스템 설계 및 역할 분담(ERD 작성, 기술 스택 확정)
Day3 : 시스템 설계(API 설계, 보안 설계), 프로젝트 개시
Day4 : Entity 작성,  RESTful API 기반 백엔드 개발, 배포 환경 구축 및 CI/CD를 이용한 배포
Day5 : RESTful API 기반 백엔드 개발, 외주 작업 시작
Day6 : RESTful API 기반 백엔드 개발,  테스트 코드 작성, 외주 1차 개발 및 피드백 및 수정 작업
Day7 : RESTful API 기반 백엔드 개발, 테스트 코드 작성
Day8 : RESTful API 기반 백엔드 개발, 테스트 코드 작성 및 코드/시스템 사용 가이드 작성 후 외주 작업 전달
Day9 : 개발 마무리 및 통합 테스트 진행, 문서 작업
Day10 : 최종 문서 정리 및 발표

 
기능 소개

기능명 설명 연관 테이블 및 컬럼
회원 관리 학생과 강사의 회원가입 및 로그인 기능 제공 student, instructor
강의 등록 강사가 새로운 강의 및 강의 자료(동영상, 문서 등)를 업로드할 수 있는 기능 course, lecture, content
강의 수강 신청 학생이 특정 과정을 수강 신청할 수 있는 기능 registration
수강 현황 관리 학생별 수강 신청 현황 및 수강 상태를 확인할 수 있는 기능 registration
퀴즈 생성 및 관리 강사가 퀴즈를 생성하고 문제를 추가할 수 있는 기능 quiz, question, answer
퀴즈 제출 학생이 퀴즈 문제에 대한 답변을 제출할 수 있는 기능 answer
퀴즈 성적 관리 퀴즈 점수를 자동 또는 수동으로 채점하여 성적을 등록할 수 있는 기능 quiz_grade
과제 등록 및 관리 강사가 과제를 생성하고 과제 설명, 제출 기한 등을 설정할 수 있는 기능 assignment
과제 제출 학생이 과제를 제출할 수 있는 기능 submission
과제 성적 평가 강사가 제출된 과제를 평가하고 점수를 등록할 수 있는 기능 assignment_grade
강의 자료 업로드 강사가 강의 자료(파일 등)를 업로드 및 관리할 수 있는 기능 content
강의 시간 및 설명 관리 강의 제목, 강의 설명, 강의 URL 등을 설정 및 삭제할 수 있는 기능 lecture
코스(과정) 관리 강사가 학습 코스를 생성하고 설명 및 시작/종료 일자를 설정 및 수정할 수 있는 기능 course
학생-강의 성적 조회 학생과 강사가 해당 강의 및 퀴즈, 과제 성적을 확인할 수 있는 기능 quiz_grade, assignment_grade
로그 기록 및 시간 관리 데이터 생성 및 수정 시간을 자동으로 기록 created_at, updated_at
수강 상태 변경 수강 신청 상태를 등록, 승인, 취소 등으로 변경할 수 있는 기능 registration

 
우리의 ERD는 아래와 같다.

처음에 간단하게 프로젝트를 진행하자에서 좀 더 erd가 커진 것 같아서 좀 당황하였지만 모두 빠짐없이 개발할 수 있었다!
+++ 이번에 erd 설계를 진행하면서 처음에 막막하기만 했던 ERD 설계였는데 어떤 요소를 고려하면서 erd를 짜면 좋을지 정리할 수 있는 계기가 되었다.
정리한 내용에 대해서는 따로 포스팅을 진행하겠다!!(나 : 무조건 쓴다.) -> 작성완료~! 

📌 ERD 설계 가이드 (+ 직접 겪은 ERD 설계 경험)

 

📌 ERD 설계 가이드 (+ 직접 겪은 ERD 설계 경험)

프로젝트를 진행하면서 꼭 거쳐야 하는 과정이 있다. 매우 중요한 부분이고 팀원들이 모두 참여해야 한다.바로 "ERD 설계"이다.처음에 ERD를 설계할 때는 막막한 감정이 가장 먼저 떠올랐던 것 같

keepgoingforever.tistory.com

 

 

추가적으로 고객팀과 진행한 외주에 대해서도 이야기해보겠다.
해당 프로젝트는 콘텐츠(도서, 영화)에 대해서 리뷰를 작성하여 감상평을 사용자들과 자유롭게 나누는 문화 커뮤니티 플랫폼이다.

우리는 이 프로젝트에서 아래의 요구사항을 담당하게 되었다.
나는 그중에서 도서 정보를 알라딘 OpenApi를 통해서 매주 신간 도서들을 업데이트하는 업무를 담당하게 되었다!
1. 책, 영화 정보 삽입 / 업데이트
일정 주기마다 호출되어 영화/책에 대한 최신 정보를 업데이트하는 API

  • 최신 한국 영화와 책을 먼저 삽입.
  • 이후 새롭게 개봉하거나 발간되는 영화와 책을 업데이트 (일주일에 한 번씩)
  • 배우 테이블에 영화에 출연하는 배우 정보도 저장 (배우가 없으면 새롭게 추가)

2. 배우별 출연 영화 조회
특정 배우를 검색하면 배우가 출연한 영화들을 조회하는 API

  • 배우별 영화까지 정리 (contents-actor 테이블 참고)

 
우리 GoGet팀 역할 분담은 도메인을 중심으로 나눠서 하였다.

이전 1차 프로젝트에서는 누군가 클론 프로젝트를 진행한 것을 바탕으로 코드 리팩토링을 진행했었다.
해당 프로젝트에 대해서도 회고를 진행할 예정이다.(이것도 꼭 작성할 거임)
그 경험을 바탕으로 좀 더 코드에 신경을 써서 진행했던 것 같다.
매번 프로젝트를 경험하면서 더 나은 개발자로 성장하는 것 같아서 정말 좋다!
 
내가 맡은 부분 기능이 포함된 usecase이다.
최종 발표를 진행하면서 개발한 것에 대해서 usecase를 작성했다는 것에 큰 칭찬을 받았다!(뿌듯!ㅎㅎ)


 
이제는 프로젝트를 진행하면서 겪었던 고민들과 얻은 지식들에 대해서 이야기해보고자 한다!
이렇게 정리하는 과정을 거쳐야 진정한 내 지식이 될 수 있다고 생각하여 이런 정리를 시작하게 되었다. 그리고 더 나아가 팀원들의 코드도 더 자세히 살펴서 내 지식으로 만들고자 하는 게 내 목표이다(다음 포스팅이 될 수 있길)!
크게 나누자면 얻은 것들의 주제들은 아래와 같다.

코드를 작성하며 얻은 지식
git과 github로 협업하면서 얻은 지식
도커를 사용하며 얻은 지식
aws를 사용하며 얻은 지식

 

1. 코드를 작성하며 얻은 지식/고민

@NotNull, @NotEmpty, @NotBlank

  • 이 애노테이션들은 Java에서 유효성 검증을 위한 제약 조건 애노테이션이다. Bean Validation(Hibernate Validator)에서 사용된다. 각 애노테이션 별로 어떤 상황에 필요한지에 대해서 대략적으로만 알고 제대로 사용하지 못한 것 같아 짚고 넘어가기로 하였다.
  • @NotNull : 필드가 null이 아니어야 한다. `null`만을 검증하며 빈 문자열이나 빈 컬렉션인 경우 신경 쓰지 않는다.
    • 모든 객체 타입(String, List, Map 등)에서 적용할 수 있다. 
    • null 이면 유효성 검증이 실패하고, "" 또는 " " 인 경우에는 유효하다.
  • @NotEmpty : 필드가 null이 아니고, 빈 값이 아닌지를 검증한다.
    • String, Collection, Map, Array에서 적용할 수 있다.
    • 길이가 0인 경우 유효성 검증에 실패한다.
    • null 이거나 "", 빈 배열인 경우에는 유효성 검증이 실패하고,  " "처럼 공백 문자만 포함된 경우 유효성 검증이 성공한다.
  • @NotBlank : 필드가 null이 아니고, 빈 값이 아니며, 공백 문자만 포함해서도 안된다.
    • String 타입에 사용된다.
    • null이거나, ""(빈 값), 공백 문자열("  ")인 경우 유효성 검증이 실패한다. 공백 이외의 문자가 포함된 경우만 유효성 검증을 성공한다.

Dto 설계

  • Controller와 Service 계층 간의 DTO 사용 방식에 대해서 이전에는 고민 없이 Service에서 Response DTO를 반환하여 Controller에서 그대로 응답 값을 사용하였다.
    • layer 간 데이터 흐름
      • Controller → Service
        • Controller는 클라이언트 요청(Request DTO)을 Service로 전달.
      • Service → Repository
        • Service는 비즈니스 로직 처리 후 Repository에서 데이터를 조회/저장.
      • Service → Controller
        • Service는 도메인(Entity)을 Response DTO로 변환해 Controller에 반환.
      • Controller → Client
        • Controller는 Response DTO를 클라이언트에 반환.
/**
 *강좌 생성 (createCourse) 흐름
 *Controller: 클라이언트 요청을 받고 Request DTO를 Service로 전달.
 *Service:
   *Authentication 정보를 통해 현재 사용자를 확인.
   *Instructor와 요청 데이터를 검증.
   *CourseMapper를 사용해 Request DTO를 도메인(Entity)로 변환.
   *Repository에 도메인(Entity) 저장 후, 결과를 Response DTO로 변환.
 *Controller: 생성된 강좌 정보를 클라이언트에 반환.
*/
// Controller
@PostMapping
public ResponseEntity<CourseCreateResponseDto> createCourse(@RequestBody @Valid CourseCreateRequestDto requestDto) {
    CourseCreateResponseDto response = courseService.createCourse(requestDto);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

// Service
@Transactional
public CourseCreateResponseDto createCourse(CourseCreateRequestDto requestDto) {
    Instructor instructor = courseValidationService.validateInstructor();
    Course course = courseMapper.toEntity(requestDto);
    course.addTeaching(Teaching.of(instructor, course));
    Course savedCourse = courseRepository.save(course);
    return courseMapper.toCreateResponseDto(savedCourse);
}
    • 이번엔 위와 같이 코드를 구현하면서 서비스 코드와 컨트롤러의 결합도가 높아 보이는 생각이 들어서 Layered Architecture 설계에 대해서 고민해 보게 되었다. 
    • 참고 블로그 1에 따르면, 아래 두 가지 선택지에 대해서 고민했다고 하였다.
      • Service에서 Response Dto 생성 후 Controller에서 반환
      • Service에서 도메인 자체를 반환하여 Controller에서 Response Dto로 변환 후 사용
    • 대규모 시스템과 유지 보수성을 고려하였을 때 두 번째 방법인 도메인을 반환하여 컨트롤러에서 dto를 변환해서 사용하는 것이 더 적합하다고 한다.
      •  비즈니스 로직을 담당하는 service 계층에서 View에 종속적인 ResponseDto를 반환하는 것은 Service의 책임이 아니라 Controller의 책임이다. 요청 Format이 변할 때 Service 계층까지 변경 영향이 미치지 않아야 한다.(RequestDto 도 동일하게 적용해야 한다.)
      • 이를 통해서, service-controller의 강한 결합도를 약하게 만들 수 있고, 책임 분리를 통해서 Service 계층의 독립성을 유지할 수 있다고 한다.
      • 또한 요청/응답 형식 변경이 일어나도 Service 로직이 영향을 받지 않는다.
      • 그렇기 때문에, 테스트 또한 용이해지는 이점을 경험할 수 있다. 
    • PR 요청 과정에서 팀원들에게 의견을 제시해 보았다! 하지만 이번 프로젝트에서는 기간이 매우 짧았고 여러 명과 협업하는 과정에서 정하는 게 쉽지 않았다..(내가 의견을 제시할 때 자세하게 내용에 대해서 설명하지 못한 부분이 매우 아쉽게 생각된다..! -> 다음엔 더 내용을 파악하고 타당한 근거와 함께 설득을 해봐야겠다는 다짐을 하게 되었다.)

  • 하지만 두 번째 치명적인 단점은 컨트롤러에 Domain이 그대로 노출된다는 것이다.
  • 그래서 해결 방법으로는 Service -> controller 간의 Dto를 만들어서 사용하는 것이다. 이 해결방법에 대해서 더 자세히 찾아보았다.
  • 참고 블로그 2를 보면 위의 코드 예시처럼 service에서 controller에서 받은 request type을 그대로 받아서 사용한다면,
    • Service가 받고 싶은 포맷(Parameter)이 Controller에 종속적이게 되고,
    • 서비스 레이어가 모듈로 분리되는 경우 해당 Type을 사용할 수 없고,
    • 트랜잭션으로 처리되어야 하는 DTO 항목이 항상 요청으로 들어온 값과 동일하지 않을 수 있다는 문제가 발생한다.
  • 이 경우에 발생할 수 있는 문제들을 해결하기 위해, 서비스 포맷에 맞게 변환해서 전달하는 방법을 사용할 수 있다.
    • 즉, 서비스는 자신이 원하는 포맷에 맞게 데이터를 받고, 컨트롤러에서 그 포맷을 만들어주는 것이 적절하다.
    • 이러한 방식은 컨트롤러에서 받는 요청의 format과 서비스에서 받는 요청의 format이 다를 가능성이 있기 때문에 유용하다.
    • 특히, 외부 API가 컨트롤러 단에 존재할 때, Web에서 들어온 Request Dto와 외부 API 호출 후에 Service로 전달될 Dto의 포맷이 달라진다는 상황이 있을 수 있다. 
    • 그래서, Service -> controller 간의 Dto를 만들어서 사용하는 방법을 통해, 레이어의 책임을 확실히 분리하고 레이어 간의 의존성을 줄여서 유지보수가 편리해지도록 해야 한다.

하지만 아직 참고 블로그 1 작성자처럼 나 또한 controller와 service 단에서 요청 포맷이 다른 경우를 많이 경험하지 못해 이 경우에 대해서 크게 다가오지 않았다.
그래서 이번 프로젝트에서는 일반적인 1번 방법으로 진행했었다.
다음 대규모 프로젝트에서는 2번 방법을 통해서 레이어 간 의존성을 낮추고, 더 나은 유지보수가 가능하도록 코드를 구현해 봐야겠다는 생각이 들었다.
참고 블로그 1, 참고 블로그 2


Mapper

mapper는 DTO와 Entity 간 변환 로직이 자주 쓰이는데 매번 작성하면 중복 코드가 많아지고 유지보수에 어려움이 생기기 때문에 도입하였다. 이번 프로젝트에서도 mapper를 통해 변환 로직을 캡슐화하여 코드 중복을 방지하였다.
그중에 mapstruct를 선택하여 사용하였다. 이유는 아래와 같다.

  • 런타임 reflection 대신 컴파일 타임에 매핑 코드를 생성하여 성능이 우수하다.(ex. ModelMapper는 런타임 reflection 기반이어서 느리고, 매핑 오류가 런타임에 발생한다.)
Reflection은 Java에서 런타임 시점에 클래스, 메서드, 필드 등의 메타데이터에 접근하고 이를 조작할 수 있는 기능이다. 컴파일 시점에 정의된 코드와 달리, Reflection은 프로그램이 실행되는 동안 동적으로 객체를 생성하거나 메서드를 호출하거나 필드 값을 변경한다.
  • 간단한 애노테이션 기반 매핑
  • 특정 필드를 무시하거나 유연한 매핑 로직
  • Spring과의 통합 용이성
/**
*Entity와 DTO 간 변환
*CourseCreateRequestDto → Course 변환:
* ID는 무시(@Mapping(target = "id", ignore = true)).
*Course → CourseCreateResponseDto 변환:
* 학생 수(courseStudents)를 기본값으로 설정.
*/
@Mapper(componentModel = "spring")
public interface CourseMapper {

    @Mapping(target = "id", ignore = true)
    Course toEntity(CourseCreateRequestDto dto);

    @Mapping(target = "courseStudents", constant = "0")
    CourseCreateResponseDto toCreateResponseDto(Course course);
    
    /**
    * @MappingTarget을 사용해 기존 Entity를 수정:
	*	CourseUpdateRequestDto의 값을 Course 엔티티에 매핑.
	*	MapStruct의 updateEntityFromDto 메서드로 구현.
    */
	default void updateEntityFromDto(CourseUpdateRequestDto dto, @MappingTarget Course course) {
        course.updateCourse(
            dto.getCourseTitle(),
            dto.getCourseDescription(),
            dto.getStartDate(),
            dto.getEndDate(),
            dto.getCourseCapacity()
    	);
    }
    /**
    * InstructorInfo를 포함한 CourseResponseDto 생성:
    * 등록된 학생 수(courseStudents) 계산.
    * 강사 정보 매핑(instructorInfo).
    */
    @Mapping(target = "instructorInfo", source = "instructorInfo")
    @Mapping(target = "courseStudents", expression = "java(course.getRegistrations() != null ? course.getRegistrations().size() : 0)")
    CourseResponseDto toResponseDto(Course course, InstructorInfo instructorInfo);
    }
}

mapstruct를 사용해 팀원 분께 좋은 피드백을 받을 수 있었다!


Fixture

다른 팀원분의 코드를 리뷰를 하면서 Test Fixture에 대해서 알게 되었다.

Test Fixture는 테스트를 실행하기 전에 필요한 고정 데이터 또는 환경 설정을 말한다. Fixture는 테스트 테이터를 준비하고 초기화하는 데에 사용된다. 테스트 코드에서 일관된 데이터와 조건을 제공해서 테스트의 신뢰성을 보장한다. 
- 테스트의 일관성 유지
- 코드 중복 제거
- 가독성 향상
- 테스트 실패 시 원인 파악 용이

이번 프로젝트에서는 enum을 사용하여 각 Fixture 데이터를 정의하였다. 또한, DTO, Entity, Response 변환 메서드를 포함하여 테스트 데이터의 재사용성을 높였다. 그래서 Fixture를 통해 Entity나 DTO 객체를 쉽게 생성하였다.

이번 기회에는 Enum으로 고정된 테스트 값으로 진행하였는데 동적인 값으로 진행해야 할 경우도 대비하여 동적 데이터 생성 방식을 결합하여 테스트해서 유연성을 확보하는 것도 고려해 봐야겠다.


@NoRepositoryBean

처음에 아무 고민 없이 repository 인터페이스에서 @repository 애노테이션을 사용했다. 하지만 JPA Repository에서 @Repository가 필요 없다는 피드백을 받았다.
해당 내용에 대해 꼭 정리해야겠다는 생각이 들었다.

Spring Data JPA는 인터페이스 기반의 Repository 구현을 자동으로 생성한다. Spring Data JPA는 개발자가 명시적으로 구현 클래스를 작성하지 않아도 자동으로 구현체를 제공하여 사용할 수 있다.

public interface CourseRepository extends JpaRepository<Course, Long> {
    // 구현체 없이 메서드 선언만으로도 동작
}

Spring Data JPA의 자동 구현 과정을 보면
일단 JpaRepository 인터페이스를 상속하여 Spring Data JPA가 이를 인식한다. 그 후 Spring Data JPA는 실행 시점에 프록시 객체를 생성하고, 인터페이스 메서드에 대한 구현체(SimpleJpaRepository)를 통해 동작한다. 이 구현체는 내부적으로 JPA의 EntityManager를 사용해 데이터베이스 작업을 수행한다.

그리고 Spring Data JPA는 이 프록시 구현체를 Spring 컨텍스트에 Bean으로 등록한다. 그래서 @Repository 애노테이션을 사용하지 않아도 Repository 인터페이스가 SpringBean으로 등록된다.
다시 계층적으로 파악하기 위해서, JpaRepository 계층으로 올라가 보면, @NoRepositoryBean이라는 애노테이션이 보인다.
@NoRepositoryBean이 선언된 인터페이스는 직접 구현되지 않는 상위 인터페이스의 Bean 등록을 방지하기 위해 Spring 컨텍스트에 직접 Bean으로 등록되지 않는다.
그렇기에 JpaRepository는 상속을 위한 추상적인 계약만 제공한다. 이를 상속받은 하위 Repository 인터페이스만 Spring Bean으로 등록된다.

SimpleJpaRepository를 보면 @Repository가 명시된 것을 볼 수 있다. Repository의 실제 구현체는 SimpleJpaRepository이다. JPA의 쿼리는 무조건 트랜잭션 내에서만 수행될 수 있다. @Transactional 애노테이션을 통해 AOP가 적용된 트랜잭션 처리를 하고 있는 것을 볼 수 있고, @Repository 애노테이션을 사용해서 JPA 쿼리 예외 발생 시에 스프링의 예외 변환기를 통해서 예외가 변환되는 모습도 볼 수 있다.

그래서 SimpleJpaRepository에 @Repository가 선언되어 있으므로, 별도로 인터페이스에 @Repository를 선언할 필요가 없다는 것을 알 수 있었다!!
참고 블로그


global exception handler

Global Exception Handler는 Spring에서 애플리케이션 전역에서 발생하는 예외를 한 곳에서 처리하기 위한 메커니즘이다. Spring Boot는 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역 예외 처리 기능을 제공한다.
이번 프로젝트에서 적용하지 못해 아쉬운 부분이다.
global exception handler를 사용하면 

  • 여러 컨트롤러에서 발생하는 예외를 한곳에서 처리하여 코드 중복 제거 및 유지보수가 용이하다.
    • try-catch를 사용하지 않고 처리할 수 있다.
  • 클라이언트가 예외 상황에서 일관된 응답 형식을 받을 수 있도록 구성할 수 있다.
  • 예외 정보를 한 곳에서 로깅하고 디버깅하기 쉽다.
  • 특정 예외에 대한 사용자 정의 처리를 쉽게 구현 가능하다.

예시로 아래와 같이 작성한다면,

@RestControllerAdvice
public class GlobalRestExceptionHandler {

    // 1. 일반 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleGlobalException(Exception ex) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("An unexpected error occurred: " + ex.getMessage());
    }

    // 2. IllegalArgumentException 처리
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body("Invalid input: " + ex.getMessage());
    }

    // 3. Validation 실패 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(errors);
    }
}

CourseController에서 추가적인 예외 처리가 필요하지 않아 클린 코드 작성이 가능하다. 또한 서비스 계층에서 발생하는 예외도 처리하여 서비스 레이어에서도 예외가 발생했을 때 명시적으로 응답 로직을 작성하지 않아도 된다.

@RestController
@RequestMapping("/api/course")
@RequiredArgsConstructor
public class CourseController {

    private final CourseService courseService;

    @PostMapping
    public ResponseEntity<CourseCreateResponseDto> createCourse(@Valid @RequestBody CourseCreateRequestDto requestDto) {
        CourseCreateResponseDto response = courseService.createCourse(requestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

꼭 다음 프로젝트에서 적용하여 유지보수가 편안한 코드로 만들고 싶다.


assertAll, assertThat, assertSoftly

테스트 코드를 작성했을 때 asserThat만 사용하던 경향이 있었다. 하지만 assertThat을 많이 사용할 때 반복적이 코드가 반복되어서 한 팀원 분께서 이 부분에 대해서 짚어주셨다.

assertThat은 AsserJ 라이브러리에서 제공하는 메서드이다. 테스트 코드에서 조건을 검증할 수 있고, 조건이 자연어처럼 기술되어 테스트 코드의 가독성이 높아진다. 하지만 assertThat을 여러 줄 사용하면 한 검증문이 실패하면 바로 테스트가 종료된다고 한다.
assertAll을 사용하면 여러 개의 assertion을 그룹화하여 실행할 수 있다. 각 assertion의 결과를 개별적으로 평가해 하나의 assertion이 실패해도 나머지 assertion이 실행된다. 그래서 모든 assertion에 대해서 결과를 보고 싶을 때 사용하는 것이 좋다. assertThat보다 더 유용하게 쓰일 것 같았다.
팀원분께서 assertAll을 추천해 주셨지만 나는 assertSoftly를 사용해 보았다.
assertSoftly는 assertAll과 비슷하지만 어디 line에서 에러가 났는지 알려주는 기능도 갖고 있어 해당 메서드를 사용하게 되었다.
팀원 분의 조언으로 인해서 더 효율적인 코드를 쓸 수 있게 성장한 것 같아 정말 감사했다!


정적 팩토리 메서드 설계

팀장님께서 정적 팩토리 메서드 적용에 대해서 언급해 주셨다! 이는 고려하지 않고 생성자만 사용했었는데 해당 내용을 이해하고 적용하기로 하였다.
정적 팩토리 메서드는 클래스의 인스턴스를 반환하는 정적 메서드를 말한다. 이는 생성자 호출을 대체하고 가독성을 향상한다.
장점으로는, 메서드 이름으로 객체 생성의 목적과 의도를 명확히 표현할 수 있다. 또, 동일한 객체를 반환하거나, 불변 객체를 캐싱하여 반환 가능하다. 또한 생성된 객체의 정확한 타입을 감출 수 있어 캡슐화에 유리하다.
기존 생성자를 사용하는 경우:

public Course(String courseTitle, String courseDescription, LocalDate startDate, LocalDate endDate, int courseCapacity) {
    this.courseTitle = courseTitle;
    this.courseDescription = courseDescription;
    this.startDate = startDate;
    this.endDate = endDate;
    this.courseCapacity = courseCapacity;
}

정적 팩토리 메서드를 사용하는 경우:

정적 팩토리 메서드를 사용하면 객체 생성 과정에서 유효성 검사가 필요하거나 기본 값을 설정할 때 높은 가독성으로 객체를 생성할 수 있다.
코드 가독성을 높이는 일은 개발에 좋은 영향을 끼친다고 생각한다. 앞으로 정적 팩토리 메서드를 적절하게 사용하여 더 나은 개발을 해야겠다. 


@Scheduled & ThreadPoolTaskScheduler

처음 외주 프로젝트를 맡았을 때 주 1회 신작 도서를 업데이트하는 로직을 담당하여 @Scheduled 애노테이션을 통해서 개발하면 되겠다고 생각하였다. 개발을 시작하고 나서 혹시나 개발 방향이 맞지 않을까 해서 팀장님께 여쭤보았더니, schedule을 동적으로 시작/중지할 수 있는 것으로 개발하는 것이 어떻냐고 하셔서 ThreadPoolTaskScheduler를 사용하게 되었다.
그래서 /api/book/start를 호출하면 수요일에 한 번씩 신작 도서들을 호출하는 API를 불러 데이터베이스에 추가하는 스케줄을 등록하도록 하였다. 또한 /api/book/stop을 호출하면 해당 스케쥴이 제거되도록 하였다.
다시 스케쥴 관리에 대해서 자세하게 알아보겠다.
Spring Framework는 작업을 일정에 따라 실행하거나, 백그라운드에서 실행되는 비동기 작업을 처리하는 기능을 제공한다.
이때, @Scheduled와 ThreadPoolTaskScheduler를 많이 사용한다. 
@Scheduled는 spring에서 제공하는 간단한 작업 스케쥴링 애노테이션으로 주기적으로 실행되는 작업을 설정하기 위해 사용한다. 기본적으로 단일 스레드를 사용하여 병렬 작업에는 적합하지 않다.
또한, fixRate, fixedDelay, cron 표현식을 지원한다.

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ScheduledTask {

    @Scheduled(fixedRate = 5000) // 5초마다 실행
    public void runAtFixedRate() {
        System.out.println("Fixed rate task executed at: " + System.currentTimeMillis());
    }

    @Scheduled(fixedDelay = 5000) // 이전 작업 완료 후 5초 대기
    public void runAtFixedDelay() {
        System.out.println("Fixed delay task executed at: " + System.currentTimeMillis());
    }

    @Scheduled(cron = "0 0 12 * * ?") // 매일 12시 실행
    public void runAtCronSchedule() {
        System.out.println("Cron task executed at: " + System.currentTimeMillis());
    }
}

 
위 코드는 @Scheduled를 사용하는 예시이다. 처음엔 API를 호출해서 db에 저장하는 로직에 대해서 수요일에 한 번씩 돌게끔 단일 스레드에 등록해 두었다. 하지만 이렇게 하니 동적으로 스케줄을 등록, 제거하는 데에는 한계가 있다고 생각하여서 API 개발을 어떻게 해야 하나 고민이 들었다.
 
그래서 사용한 ThreadPoolTaskScheduler에 대해서 알아보겠다.
ThreadPoolTaskScheduler는 Spring에서 제공하는 스레드 풀 기반의 작업 스케줄링 도구이다. 스케쥴링 작업을 병렬로 실행하거나 스레드 풀 크기를 조정할 수 있는 유연성을 제공한다.
해당 도구를 사용하면 여러 작업을 병렬로 실행할 수 있고, 스레드 수를 제어할 수 있어 thread pool을 관리할 수 있다. 또한 특정 작업에 대해서 런타임에 등록하거나 제거할 수 있다. 이게 내가 사용한 가장 큰 이유이다. 또한, 등록된 작업에 대하여 동작을 세부적으로 제어할 수 있다.(ex. 작업 실행 조건, 취소, 재시도 등에 대한 관리)
실제로 프로젝트에서 구현한 내용을 보면 아래와 같다.

 

 

현재 코드에서는 ThreadPoolTaskScheduler를 활용하여 작업을 스케줄링하고 있다. CronTrigger를 사용하여 매주 수요일에 실행되도록 설정하였다. 
현재 다시 코드를 살펴보니 개선할 점이 보인다. (역시 코드는 다시 보면 다시 보인다는 걸 매번 느낀다.)
현재는 수요일에 한 번 돌아가는 프로세스이기 때문에 poolSize는 1 사이즈로 설정해도 될 것 같다고 생각이 들었다.
 
또 에러 처리 시에 JsonProcessingException을 RuntimeException으로 래핑 하면 에러의 구체적인 원인을 숨기게 되어 구체적인 예외 타입을 잘 써야겠다고 생각이 들었다.
그리고 현재 코드는 작업이 실패하면 스케쥴러가 다음 실행까지 기다리게 된다. 작업 실패 시 재시도 로직을 추가하면 안정성이 증가할 것 같다.
또 크론 표현식에 대해서도 상수로 처리하여 불변하게 하는 것이 옳을 것 같다. (ex. private static final String CRON_EXPRESSION = "0 0 0 ? * WED";)
 


2. git과 github로 협업하면서 얻은 지식

git과 github는 이전에도 많이 사용하였지만 이번에는 많이 사용한 명령어들이 있다. 이것들에 대해서 기록하고자 한다.

git reset

git reset은 Git에서 특정 커밋으로 되돌리거나, 파일의 변경 사항을 스테이징 또는 작업 디렉터리에서 제거할 때 사용한다.
 

  • --soft: 커밋만 되돌리고, 변경 사항은 스테이징 영역에 유지.
  • --mixed (기본값): 커밋과 스테이징을 되돌리고, 변경 사항은 작업 디렉터리에 유지.
  • --hard: 커밋, 스테이징, 작업 디렉터리의 변경 사항을 모두 제거.
# 마지막 커밋을 취소하고, 변경 사항을 유지
git reset --soft HEAD~1

# 마지막 커밋과 스테이징 취소, 변경 사항은 작업 디렉터리에 유지
git reset --mixed HEAD~1

# 마지막 커밋, 스테이징, 작업 디렉터리 모두 삭제
git reset --hard HEAD~1

 
여기서 git reset --soft HEAD~1 을 가장 많이 사용했던 것 같다. 로컬에서 커밋하고 항상 커밋 메시지를 틀려서 다시 작성한 게 많이 떠오른다..ㅎㅎ


git branch -m 새로운 브랜치 이름

로컬 브랜치의 이름을 변경할 때 사용한다.

# 현재 브랜치 이름을 'new-branch'로 변경
git branch -m new-branch

# 특정 브랜치 이름을 변경 (예: old-branch -> new-branch)
git branch -m old-branch new-branch

이것도 브랜치를 하나 만들어두고 이름이 맘에 들지 않거나 오타가 있을 때 자주 사용해서 이번에 아주 기억에 남게 된 것 같다..!!!!


git push --set-upstream origin 브랜치 이름

해당 명령어는 로컬 브랜치와 원격 브랜치를 연결하고, 원격에 브랜치를 푸시한다. 새로 생성한 로컬 브랜치를 원격에 푸시하고 원격 추적 브랜치와 연결할 때 사용한다.

# 'new-branch'를 원격에 푸시하고, 로컬 브랜치와 연결
git push --set-upstream origin new-branch

해당 메시지를 git에서 정말 많이 추천받았다.. 왜냐하면 로컬 브랜치를 만들고 작업을 다 한 후 아무 생각없이 git push를 진행했더니 로컬 브랜치와 원격 브랜치가 연결되지 않아 git이 해당 메세지를 추천해 줘서 결국 뇌리에 박히게 되었다!! (럭키비키~)


git commit --amend

최근 커밋 메시지를 수정하거나, 커밋에 파일을 추가/제거할 때 사용한다. 특히, 커밋 메시지 오타 수정, 변경 사항을 빠뜨렸을 때 사용한다.
하지만 나는 해당 메시지는 PR을 다 요청하고 리뷰를 받고 나서 수정사항에 대해서 업데이트할 때 마지막 커밋 내용에 리뷰에서 생긴 수정 사항들을 다시 넣어서 다시 커밋을 만들어 깔끔하게 PR을 만들 때 사용했다. 이번 프로젝트에서 처음엔 pr을 closed 하고 진행해야 하나 했는데 해당 방법을 통해서 진행했던 경험이 있다.

# 최근 커밋 메시지 수정
git commit --amend -m "수정된 커밋 메시지"

# 최근 커밋에 파일 추가
git add 수정된파일.txt
git commit --amend --no-edit  # 메시지는 수정하지 않음

github issue/pull-request 템플릿 등록

GitHub 프로젝트에서 Issue와 Pull Request를 생성할 때, 일관된 템플릿을 등록하여 협업 효율성을 높이도록 하였다. 
 

  • Issue 템플릿: .github/ISSUE_TEMPLATE/issue-template.md
  • Pull Request 템플릿: .github/pull_request_template.md

우리가 사용한 템플릿은 깃헙 프로젝트에서 확인할 수 있다! 혹시 보시는 분들께서 다른 프로젝트할 때 필요하시다면 가져다 써도 좋을 것 같다!!!
 

git stash, intellij shelved

git stash는 현재 작업 중인 변경 사항을 저장소에 임시로 저장하고, 작업 디렉터리를 초기 상태로 되돌린다. 이건 예전에 회사에서도 자주 썼었던 기능이다. 다른 브랜치를 확인해야 할 경우에 작업했던 것들을 남기고 바로 checkout 할 수 없기 때문에 git stash를 이용하여 내가 한 작업에 대해서 임시로 저장하고 다시 초기 상태로 되돌려 다른 브랜치로 이동했었던 경험이 있다.

# 작업 변경 사항 임시 저장
git stash

# 가장 최근에 저장한 변경 사항 복원
git stash pop

# 특정 stash 항목 복원
git stash apply stash@{1}

이번에 intellij를 사용하면서 stash와 비슷한 기능인 IntelliJ Shelved Changes를 경험하였다.
IntelliJ에서 작업 중인 변경 사항을 Shelve로 저장하여 임시로 보관할 수 있는 기능이고,  Git의 stash와 유사하지만, GUI 기반으로 더 직관적이다. IDE 내 해당 기능을 git stash보다 잘 사용한 것 같다 ㅎㅎ
 
 
 

  • Shelve 변경 사항 저장: IntelliJ에서 변경 사항을 선택하고 "Shelve Changes"를 클릭.
  • 복원: "Unshelve Changes"를 선택하여 이전 작업 상태로 복원.

 


3. 도커를 사용하며 얻은 지식

이전에는 Mysql만 사용했어서 mysql workbench로만 연동하였는데 이번 프로젝트에서는 refresh token에 대해서 Redis에 저장하여 사용하게 되었다. 그래서 redis와 mysql과 애플리케이션에 대해서 한 번에 컨테이너로 묶어 사용하기 위해 docker-compose 파일과 DockerFile을 작성하여 진행하게 되었다. 정리하면, 아래와 같다.
 

  • Redis: 빠른 인증 처리와 Refresh Token 저장과 효율적인 만료 관리를 위해 사용.
  • MySQL: 영구 데이터 저장 (사용자 정보, 애플리케이션 데이터 등).
  • Docker Compose: 애플리케이션, Redis, MySQL을 한 번에 실행하도록 구성.

 
Docker Compose는 여러 컨테이너를 정의하고, 종속성을 관리할 수 있는 툴이기에, Redis, MySQL, 애플리케이션 컨테이너를 하나의 docker-compose.yml 파일에 정의하여 실행을 간소화했다.
 


4. AWS를 사용하며 얻은 지식

이번에 강의 및 강의자료를 업로드하면서 S3를 사용하게 되었다.

AWS S3 (Simple Storage Service)

S3는 AWS에서 제공하는 객체 스토리지 서비스로, 파일이나 데이터를 저장 및 관리하기 위한 서비스이다. 이번 프로젝트에서는 강의 자료 및 강의 파일 업로드/관리를 위해 사용되었다.

S3의 주요 특징
- 확장성: 용량 제한 없이 파일 저장 가능.
- 고가용성: AWS의 글로벌 인프라를 기반으로 높은 내구성 보장.
- 다양한 권한 설정: 버킷 정책, ACL(Access Control List) 등을 활용한 세밀한 접근 제어.
- 비용 효율성: 사용한 만큼 과금되는 요금 모델.

 
 
처음에 S3관련 코드를 어떤 의존성을 추가해서 개발해야 하는지 막막했다.
Springboot 3.4.1 버전으로 진행하고 있었기 때문에 스프링 공식문서와 Spring Cloud AWS에 따라 현재 최신 버전은 3.0.0이고 GA(General Availability), 테스트가 완료된 정식 릴리즈 버전을 의미하는 아래 의존성을 선택하였다.

implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.0.0'

해당 의존성을 추가하니 S3Operations Bean이 자동으로 등록되어 S3Operations에서의 메서드를 사용하여 파일 업로드, 삭제 기능을 구현하였다.
이를 통해서 Spring Cloud AWS 프로젝트와 통합하여 Spring Boot 애플리케이션에서 S3를 쉽게 구성하고 사용할 수 있었다.
 
처음에 강의(lecture( 도메인에 대해서 먼저 구현했어서 일단 lecture 도메인에서 service 코드에 S3 파일 업로드, 삭제 로직을 구현하였다. 그 후, 강의자료(content) 도메인에서도 해당 로직을 사용했기에 중복 코드가 발생하여, FileService를 만들어 코드를 재사용하는 방향으로 변경하였다! FileService는 파일 업로드, 삭제, 파일명 생성, S3 URL 생성 등 파일 관리 관련 기능을 명확히 분리하고 있다. 각 메서드들이 단일 책임(SRP)을 가지도록 하였다.

package com.example.lms.common.service;

import io.awspring.cloud.s3.ObjectMetadata;
import io.awspring.cloud.s3.S3Operations;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
    private final S3Operations s3Operations;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${cloud.aws.region.static}")
    private String region;

    public void uploadToS3(MultipartFile multipartFile, String fileName) throws IOException {
        try (InputStream inputStream = multipartFile.getInputStream()) {
            s3Operations.upload(bucket, fileName, inputStream,
                    ObjectMetadata.builder()
                            .contentType(multipartFile.getContentType())
                            .build());
        }
        log.info("S3에 파일 업로드 완료: {} \n", fileName);
    }

    public String generateUniqueFileName(String originalFileName) {
        String sanitizedFileName = originalFileName.replace(" ", "_");
        String uuid = UUID.randomUUID().toString();
        return uuid + "_" + sanitizedFileName;
    }

    public String generateS3FileUrl(String fileName) {
        return String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, fileName);
    }

    public void deleteFromS3(String url) {
        // URL에서 파일 이름 추출
        String fileName = url.substring(url.lastIndexOf("/") + 1);

        // S3에서 파일 삭제 요청
        s3Operations.deleteObject(bucket, fileName);

        log.info("S3에서 파일 삭제 완료: {} \n", fileName);
    }
}

file upload logic을 구현할 때는 아래 내용을 유의하여 진행하였다.
 

  • 파일 업로드 시, 임시 디렉터리를 활용해 메모리 사용을 최소화.
  • Spring의 MultipartFile을 사용해 업로드 요청 처리.
  • 업로드 후 파일 경로를 반환하여 파일 관리 용이.

그래서 파일 업로드 메서드에서는 try-with-resources를 사용해 InputStream을 자동으로 닫아 리소스 누수를 방지하였다. (하지만, 에러 처리는 잘 되지 않은 것 같아 아쉬움이 있다.) 또, ObjectMetadata를 통해 파일의 Content-Type을 설정해 S3에서 파일 유형이 명확히 구분되도록 하였다. MultipartFile은 HTTP 표준인 multipart/form-data 요청에 최적화되어 있고, 대용량 파일 처리 시 임시 파일로 저장되어 메모리 사용이 효율적으로 되도록 하였다. 
또 파일 이름의 중복 문제를 해결하기 위해 UUID를 사용하여 고유 파일 이름 생성하였고, S3에 업로드되었을 때 공백이 +가 되는 경우가 있어서 파일명 내의 공백(" ")을 언더스코어("_")로 변환해 URL이 동일하게 관리되도록 로직을 추가하였다.
 
 
 
그리고 로컬에서 개발을 진행하고 PR을 올려서 develop에 머지되었는데, AWS 코드가 추가됨에 따라 Spring 서버가 로컬에서 실행이 안 되는 오류가 발생하여 다른 팀원들이 당황하게 만들어 버렸다... (아래 내용을 공유했어야 했는데 놓쳐서 정말 당황했다.. 팀장님 감사합니다!)

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Error creating bean with name 's3Client' defined in class path resource [io/awspring/cloud/autoconfigure/s3/S3AutoConfiguration$CrossRegionS3ClientConfiguration.class]: Unsatisfied dependency expressed through method 's3Client' parameter 0: Error creating bean with name 's3ClientBuilder' defined in class path resource...

문제 원인

  1. S3 클라이언트 초기화 실패: Spring Boot가 S3Client를 생성하려고 하지만, AWS Region 설정이 제공되지 않아서 초기화에 실패.
  2. Region 설정의 부재: DefaultAwsRegionProviderChain은 AWS Region 정보를 가져오려고 여러 단계를 시도. 그러나 아래 단계들이 모두 실패.
    • 환경 변수 AWS_REGION
    • 시스템 속성 aws.region
    • AWS 프로파일 (~/.aws/credentials 또는 ~/.aws/config)
    • EC2 메타데이터 서비스

 

해결 방법

  1. AWS CLI 설치
  2. AWS Configure 설정
    • 커맨드 명령어 입력 -> aws configure
    • 배포한 S3에 대한 정보 설정
      • AWS_ACCESS_KEY_ID: -
      • AWS_SECRET_ACCESS_KEY: -
      • AWS_REGION: -
      • AWS_S3_BUCKET: -
  3. application.yml  정보 추가

회고 (5F)

Fact(사실)

  • 기획한 기능을 모두 개발하였다.
  • 외주 개발을 완성하였다.(도서 API 호출 스케쥴러 개발)
  • 디테일한 개발을 하지 못했다.
    • 세세한 test 케이스 작성
    • 에러 핸들링
  • 코드를 추가하고 특정 설정을 해야 동작하는 문제에 대해서 팀원들에게 명확하게 고지하지 못했다.

Feeling(느낌)

  • 모든 팀원들이 정말 열심히 참여해 주어서 해낼 수 있었던 프로젝트였다!
  • 피드백을 받으면서 사소한 것까지 신경 쓰고 개발할 수 있는 지식을 얻게 되었다.
  • 조금 더 소통을 하면 좋았을 것이라고 생각된다.

Finding(교훈)

  • 자신감을 갖고 팀원들과 더 소통을 해야겠다.(나의 의견을 더 내고, 팀원들의 코드를 더 유의 깊게 보고, 팀원들에게 질문을 많이 하자!)
  • 테스트 코드 작성에 더 시간을 쏟아서 정확성이 높고 신뢰성이 있으며 추후 유지보수가 잘 되는 코드를 구현해야겠다.
  • 내가 작성한 코드에 더 책임감을 갖고 필요한 조건들에 대해서 같이 개발을 진행하는 팀원들에게 잘 전달해야겠다.

Future action(향후 행동)

  • 테스트 코드에 대해서 더 탐구해서 더 작은 단위, 그리고 효율적인 테스트 코드를 작성할 수 있도록 할 것이다. 이를 통해, 격리된 환경에서 문제를 잘 해결할 수 있는 능력을 키우고 싶다.
  • 디버깅 능력을 키워 문제 해결을 할 때 더 빠르고 정확하게 해내고 싶다.
  • 여러 사용자들이 요청을 보냈을 때 동시성 제어, 대규모 트래픽 제어에 대해서도 경험하여 더 큰 시스템에 대응할 수 있는 능력을 키울 것이다.

Feedback(피드백)

  • 추후 작성.
728x90
반응형