Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
37 changes: 37 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
## [본 과정] 이커머스 핵심 프로세스 구현
[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다.
> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등)
## Multi Module Design
Module을 나누는 기준은 여러 개가 있지만, 이번 프로젝트에서는 Layered Architecture 에서 설명되는 Layer 별로 구분하였습니다.
- api: 사용자의 요청을 받고, 응답한다.
- 본래, presentation과 application 두 개의 모듈로 분리되어있던 구조를 수정하였습니다.
- 사용자의 요청을 받고 처리한다는 점에서, Error와 Response를 한 곳에서 관리하기 위함입니다.
- domain: 시스템이 제공할 도메인 규칙을 구현한다.
- infra: 외부 시스템과의 연동을 담당한다.
- 본래 존재하였던 core 모듈을 삭제하였습니다.
- 실제로 공통 역할이 아니지만 core(or common) 모듈에 패키지를 생성해서 정의하는 경우를 방지하기 위함입니다.
- 현재까지의 요구사항에서는 core 모듈에 들어갈 기능이 없다고 판단되었습니다.

각 module은 하위 module에만 의존합니다. <br>
JPA 를 다른 ORM 으로 변경될 가능성은 낮다고 판단하여 PA 는 생산성을 위해서 Entity 와 Repository 를 Domain 으로 끌어 올려 사용하였습니다.
JPA 를 제외한 나머지는 저수준의 변경사항으로 부터 고수준을 지키는 방식을 사용합니다.

## Table Design
![img_2.png](img_2.png)
- Movie 테이블과 Theater 테이블은 N:N 관계로 중간에 Screening 테이블을 두고 있습니다.
- Theater 별로 시간표가 구분되는 것을 고려하여 Screening 테이블은 상영 시간표 정보를 포함하고 있습니다.
- 좌석별 등급 등 좌석 개별의 특성이 추가될 수 있다고 생각하여 Seat 테이블을 생성하였습니다.
- Theater 테이블과 Seat 테이블은 1:N 관계입니다.
- Seat 테이블과 Reservation 테이블은 1:1 관계입니다.
- 공유 자원인 Seat과 행위인 Reservation을 분리하기 위함입니다.
- Reservation 테이블과 User 테이블은 1:1 관계입니다.

## N+1 문제 해결
저는 N+1 문제가 ID 참조을 사용하기 때문이라고 생각합니다. 따라서 해당 프로젝트에 간접참조를 사용하여, N+1 문제를 해결하고자 합니다.
뿐만 아니라, 간접 참조를 사용하면 도메인 간 물리적인 연결을 제거하기 때문에 도메인 간 의존을 강제적으로 제거되고, FK의 데이터 정합성을 따지기 위한 성능 오버헤드를 줄이며, 데이터 수정 시의 작업 순서가 강제됨에 따라 발생하는 더 큰 수정 개발을 방지할 수 있습니다.

대신, 애플리케이션 단에서 무결성 관리를 관리하도록 합니다. (삽입/수정/삭제)
9 changes: 9 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
bootJar.enabled = true

jar.enabled = false

dependencies {
implementation project(':domain')

implementation "org.springframework.boot:spring-boot-starter-web"
}
3 changes: 3 additions & 0 deletions api/http/movie.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### 1. 상영중인 영화 조회
GET localhost:8080/v1/movies/now-showing
Content-Type: application/json
24 changes: 24 additions & 0 deletions api/src/main/java/com/example/Hang99Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import java.util.Optional;
import java.util.UUID;

@EnableJpaAuditing
@SpringBootApplication
public class Hang99Application {

public static void main(String[] args) {
SpringApplication.run(Hang99Application.class, args);
}

@Bean
public AuditorAware<String> auditorProvider(){
return () -> Optional.of("SYSTEM");
}
}
20 changes: 20 additions & 0 deletions api/src/main/java/com/example/exception/BaseException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.exception;

import com.example.response.status.ResponseStatus;
import lombok.Getter;

@Getter
public class BaseException extends RuntimeException{
private final ResponseStatus exceptionStatus;


public BaseException(ResponseStatus exceptionStatus) {
super(exceptionStatus.getMessage());
this.exceptionStatus = exceptionStatus;
}

public BaseException(String exceptionMessage, ResponseStatus exceptionStatus) {
super(exceptionMessage);
this.exceptionStatus = exceptionStatus;
}
}
68 changes: 68 additions & 0 deletions api/src/main/java/com/example/exception/BaseExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.example.exception;

import com.example.response.BaseErrorResponse;
import com.example.response.code.BaseCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.TypeMismatchException;
import org.springframework.http.HttpStatus;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.io.IOException;

@Slf4j
@RestControllerAdvice
public class BaseExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler({BaseException.class,NoHandlerFoundException.class, TypeMismatchException.class})
public BaseErrorResponse handle_BadRequest(Exception exception) {
log.info("[BaseExceptionControllerAdvice: handle_BadRequest 호출]", exception);
return new BaseErrorResponse(BaseCode.URL_NOT_FOUND);
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public BaseErrorResponse handle_HttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.info("[BaseExceptionControllerAdvice: handle_HttpRequestMethodNotSupportedException 호출]", e);
return new BaseErrorResponse(BaseCode.METHOD_NOT_ALLOWED);
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public BaseErrorResponse handle_IllegalArgumentException(IllegalArgumentException e) {
log.info("[BaseExceptionControllerAdvice: handle_IllegalArgumentException 호출]", e);
return new BaseErrorResponse(BaseCode.BAD_REQUEST, e.getMessage());
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalStateException.class)
public BaseErrorResponse handle_IllegalStatusException(IllegalStateException e) {
log.info("[BaseExceptionControllerAdvice: handle_IllegalStatusException 호출]", e);
return new BaseErrorResponse(BaseCode.BAD_REQUEST, e.getMessage());
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IOException.class)
public BaseErrorResponse handle_IOException(IOException e) {
log.info("[BaseExceptionControllerAdvice: handle_IOException 호출]", e);
return new BaseErrorResponse(BaseCode.BAD_REQUEST, e.getMessage());
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MissingServletRequestParameterException.class)
public BaseErrorResponse handleMissingServletRequestParameterException(MissingServletRequestParameterException e) {
log.info("[GlobalExceptionHandler] MissingServletRequestParameterException", e);
return new BaseErrorResponse(BaseCode.NO_REQUEST_PARAMETER);
}

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(RuntimeException.class)
public BaseErrorResponse handle_RuntimeException(Exception e) {
log.error("[BaseExceptionControllerAdvice: handle_RuntimeException 호출]", e);
return new BaseErrorResponse(BaseCode.SERVER_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.movie.application.convertor;

import com.example.movie.repository.dto.MoviesNowShowingDetailDto;
import com.example.movie.application.dto.MoviesNowShowingDetail;
import com.example.movie.application.dto.ScreeningTimeDetail;
import com.example.movie.application.dto.ScreeningsDetail;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

@Component
@NoArgsConstructor
public class DtoConvertor {
public List<MoviesNowShowingDetail> moviesNowScreening(List<MoviesNowShowingDetailDto> dbResults) {
return dbResults.stream()
.collect(Collectors.groupingBy(MoviesNowShowingDetailDto::getMovieId))
.entrySet().stream()
.map(entry -> {
Long movieId = entry.getKey();
List<MoviesNowShowingDetailDto> groupedByMovie = entry.getValue();


MoviesNowShowingDetailDto firstEntry = groupedByMovie.get(0);


List<ScreeningsDetail> screeningsDetails = groupedByMovie.stream()
.collect(Collectors.groupingBy(MoviesNowShowingDetailDto::getTheaterId))
.entrySet().stream()
.map(theaterEntry -> {
Long theaterId = theaterEntry.getKey();
String theaterName = theaterEntry.getValue().get(0).getTheaterName();
List<ScreeningTimeDetail> screeningTimes = theaterEntry.getValue().stream()
.sorted(Comparator.comparing(MoviesNowShowingDetailDto::getStartAt))
.map(dto -> new ScreeningTimeDetail(dto.getStartAt(), dto.getEndAt()))
.toList();
return new ScreeningsDetail(theaterId, theaterName, screeningTimes);
})
.toList();

// Create the final DTO
return new MoviesNowShowingDetail(
movieId, // Add movieId here
firstEntry.getMovieName(),
firstEntry.getGrade(),
firstEntry.getReleaseDate(),
firstEntry.getThumbnail(),
firstEntry.getRunningTime(),
firstEntry.getGenre(),
screeningsDetails
);
})
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.movie.application.dto;

import com.example.movie.entity.movie.Genre;
import com.example.movie.entity.movie.Grade;

import java.time.LocalDate;
import java.util.List;

public record MoviesNowShowingDetail (
Long movieId,
String movieName,
Grade grade,
LocalDate releaseDate,
String thumbnail,
Long runningTime,
Genre genre,
List<ScreeningsDetail> screeningsDetails
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.movie.application.dto;

import java.time.LocalDateTime;

public record ScreeningTimeDetail(
LocalDateTime startAt,
LocalDateTime endAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.movie.application.dto;

import java.util.List;

public record ScreeningsDetail(
Long theaterId,
String theater,
List<ScreeningTimeDetail> screeningTimes
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.movie.application.service;

import com.example.movie.application.convertor.DtoConvertor;
import com.example.movie.application.dto.MoviesNowShowingDetail;
import com.example.movie.entity.movie.Genre;
import com.example.movie.repository.MovieRepository;
import com.example.movie.repository.dto.MoviesNowShowingDetailDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;

@Service
@RequiredArgsConstructor
public class MovieService {

private final MovieRepository movieRepository;
private final DtoConvertor dtoConvertor;

public List<MoviesNowShowingDetail> getMoviesNowShowing(LocalDateTime now, Genre genre, String search) {
List<MoviesNowShowingDetailDto> dbResults = movieRepository.findNowShowing(now);
List<MoviesNowShowingDetail> detailsList = dtoConvertor.moviesNowScreening(dbResults);

return detailsList.stream()
.sorted(Comparator.comparing(MoviesNowShowingDetail::releaseDate))
.toList();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.movie.presentation.controller;

import com.example.movie.application.dto.MoviesNowShowingDetail;
import com.example.movie.application.service.MovieService;
import com.example.movie.entity.movie.Genre;
import com.example.response.BaseResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.List;

@RestController
@RequiredArgsConstructor
public class MovieController {

private final MovieService movieService;

@GetMapping("/v1/movies/now-showing")
public BaseResponse<List<MoviesNowShowingDetail>> getMoviesNowShowing(
@RequestParam(value = "genre", required = false)Genre genre,
@RequestParam(value = "search", required = false)String search
) {
List<MoviesNowShowingDetail> response = movieService.getMoviesNowShowing(LocalDateTime.now(), genre, search);
return new BaseResponse<>(response);
}

}
Loading
Loading