Skip to content
Merged
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,4 +292,94 @@ Access Token의 유효기간을 짧게 하면 로그인을 자주해야해서
**장단점**

- (장) 소셜 계정으로 빠른 온보딩, 외부 자원 접근 위임 표준.
- (단) 리다이렉트/코드 교환 등 플로우 복잡, 제공자별 설정·검증 필요.
- (단) 리다이렉트/코드 교환 등 플로우 복잡, 제공자별 설정·검증 필요.


## 코드 리팩토링 (책임 분리)
참고)
https://youtu.be/dJ5C4qRqAgA?si=WAgUBNGA9G_B8Vl0

![Image](https://github.com/user-attachments/assets/519b2c85-22ac-48a1-a2da-5651278bc809)

![Image](https://github.com/user-attachments/assets/a7206b5d-1279-42bd-be96-81907555bb2b)

![Image](https://github.com/user-attachments/assets/18e4266f-835c-4e4f-add3-f0a29a280ab6)

![Image](https://github.com/user-attachments/assets/a8096df5-93f0-4eba-88b5-9720ca202d9a)

객체 참조는 결합도가 가장 높은 의존성 -> 객체 참조를 끊어 결합도를 낮추자!

![Image](https://github.com/user-attachments/assets/88998601-3531-4993-8a00-ba01c65221cd)

![Image](https://github.com/user-attachments/assets/17161707-3a10-4150-98bd-dfc0146a82f2)


1. 의존성 설계 수정
@ManyToOne, @OneToMany 관계로 풀어낸 Entity 관계들 중
결합도가 높을 필요가 없는 경우, Entity 참조 -> id 참조할 수 있도록 Entity 설계 수정

2. Service layer 책임 분리
ex) Member Domain Service
```
@Service
@RequiredArgsConstructor
public class MemberReader {

private final MemberRepository memberRepository;

/** 회원 단건 조회 */
public Member getById(Long memberId) {
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다. id=" + memberId));
return Member.from(member);
}
}
```

```
@Service
@RequiredArgsConstructor
public class MemberSaver {

private final MemberRepository memberRepository;

/** 회원 가입 */
@Transactional
public Long execute(CreateMemberCommand createMemberCommand) { // member join command
MemberEntity saved = memberRepository.save(createMemberCommand.toEntity());
return saved.getId();
}

}
```

```
@Service
@RequiredArgsConstructor
public class AuthService {

private final MemberReader memberReader;
private final MemberSaver memberSaver;
private final PasswordEncoder passwordEncoder; // BCryptPasswordEncoder

@Transactional
public void signUp(SignUpRequest req) {
// 이미 존재하는 loginId 체크
memberReader.getByLoginId(req.loginId());

// 새 회원 생성 (비밀번호는 반드시 암호화)
CreateMemberCommand m = new CreateMemberCommand(
req.name(),
req.age(),
req.gender(),
req.loginId(),
passwordEncoder.encode(req.password())
);

memberSaver.execute(m);

}
}
```

Controller가 참조하는 Service의 Repository 의존성을 없앨 수 있다!
2 changes: 1 addition & 1 deletion cgv_clone/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ out/
.vscode/

### env ###
.env
src/main/resources/local.env
5 changes: 4 additions & 1 deletion cgv_clone/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'

// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'

// database
// runtimeOnly 'com.h2database:h2'
Expand All @@ -53,6 +53,9 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

//webclient
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
40 changes: 40 additions & 0 deletions cgv_clone/src/main/java/com/ceos22/cgv_clone/DevDataSeeder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//package com.ceos22.cgv_clone;
//
//import com.ceos22.cgv_clone.domain.reservationMovie.Movie;
//import com.ceos22.cgv_clone.repository.MovieRepository;
//import jakarta.transaction.Transactional;
//import lombok.RequiredArgsConstructor;
//import org.springframework.boot.CommandLineRunner;
//import org.springframework.stereotype.Component;
//
//import java.util.List;
//
//@Component
//@RequiredArgsConstructor
//public class DevDataSeeder implements CommandLineRunner {
//
// private final MovieRepository movieRepository;
//
// @Override
// @Transactional
// public void run(String... args) {
// if (movieRepository.count() > 0) return; // 중복 삽입 방지
//
// List<Movie> movies = List.of(
// new Movie("서울의 봄", 140, "1979년 12월, 계엄 하의 정치 격변 속 실제 사건을 모티브로 한 드라마."),
// new Movie("오펜하이머", 180, "핵개발 프로젝트의 딜레마와 인물의 내면을 그린 전기 드라마."),
// new Movie("듄: 파트2", 166, "사막 행성 아라키스에서 펼쳐지는 권력과 예언의 서사."),
// new Movie("인사이드 아웃 2", 96, "사춘기 감정들이 늘어나며 벌어지는 좌충우돌 성장기."),
// new Movie("콘크리트 유토피아", 130, "재난 이후 한 아파트에 모인 생존자들의 이야기."),
// new Movie("범죄도시 4", 109, "형사 마석도의 거침없는 범죄 소탕 작전."),
// new Movie("탑건: 매버릭", 131, "전설의 파일럿이 귀환해 새로운 팀을 이끄는 항공 액션."),
// new Movie("어벤져스: 엔드게임", 181, "인피니티 사가의 대미를 장식하는 히어로 대서사."),
// new Movie("라라랜드", 128, "꿈을 좇는 두 청춘의 사랑과 선택을 담은 뮤지컬 드라마."),
// new Movie("기생충", 132, "두 가족의 얽힘으로 드러나는 계급의 아이러니."),
// new Movie("인터스텔라", 169, "인류의 미래를 위한 우주 탐사와 시간의 상대성."),
// new Movie("소울", 100, "음악가의 영혼 여행을 통해 삶의 의미를 묻는 애니메이션.")
// );
//
// movieRepository.saveAll(movies);
// }
//}
20 changes: 20 additions & 0 deletions cgv_clone/src/main/java/com/ceos22/cgv_clone/UseCase.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ceos22.cgv_clone;

import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface UseCase {

/**
* Alias for {@link Component#value}.
*/
@AliasFor(annotation = Component.class)
String value() default "";

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.ceos22.cgv_clone;
package com.ceos22.cgv_clone.api.config;

import com.ceos22.cgv_clone.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
Expand All @@ -19,10 +21,8 @@ public class SecurityConfig {

private final JwtAuthenticationFilter jwtFilter;

private static final String[] SWAGGER_WHITELIST = {
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
private static final String[] SWAGGER = {
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
};

@Bean
Expand All @@ -34,18 +34,19 @@ public PasswordEncoder passwordEncoder() {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// (1) csrf - swagger 경로 예외
.csrf(csrf -> csrf
.ignoringRequestMatchers(SWAGGER_WHITELIST) // swagger 경로 예외
)
// (1) csrf
.csrf(AbstractHttpConfigurer::disable)

// (2) 세션
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

// (3) 인증 / 인가
.authorizeHttpRequests(reg -> reg
.requestMatchers(SWAGGER_WHITELIST).permitAll() // Swagger 허용
.requestMatchers("/api/auth/**").permitAll() // 로그인/회원가입
.requestMatchers(SWAGGER).permitAll() // Swagger 허용
.requestMatchers(HttpMethod.POST,"/api/auth/**").permitAll() // 로그인/회원가입
.requestMatchers("/api/reservation/**",
"/api/favorites/movies/*/*/toggle",
"/api/favorites/cinemas/*/*/toggle").permitAll()
.anyRequest().authenticated() // 나머지 인증
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ceos22.cgv_clone;
package com.ceos22.cgv_clone.api.config;


import io.swagger.v3.oas.models.Components;
Expand All @@ -21,8 +21,8 @@ public OpenAPI customOpenAPI() {

private Info apiInfo() {
return new Info()
.title("Spring RESTful API")
.description("RESTful API reference for developers")
.title("Cgv-clone API")
.description("CEOS-Developers / Immmii / spring-cgv-22nd")
.version("1.0.0");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.ceos22.cgv_clone.api.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebClientConfig implements WebMvcConfigurer {

@Value("${payment.base-url}")
private String baseUrl;

@Value("${payment.api-secret}")
private String apiSecretKey;

@Bean
public WebClient paymentWebClient() {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiSecretKey)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ceos22.cgv_clone.api.config.response;

import com.ceos22.cgv_clone.common.dto.ErrorResponse;
import com.ceos22.cgv_clone.common.dto.ErrorReason;
import com.ceos22.cgv_clone.common.exception.BaseErrorCode;
import com.ceos22.cgv_clone.common.exception.CgvCloneBusinessException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {


@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
System.out.println("MethodArgumentNotValidException 에러를 캐치");

return null; // 수정해야
}

@ExceptionHandler(CgvCloneBusinessException.class)
public ResponseEntity<ErrorResponse> handleCgvCloneBusinessException(
CgvCloneBusinessException e, HttpServletRequest request) {
BaseErrorCode code = e.getErrorCode();
ErrorReason errorReason = code.getErrorReason();
ErrorResponse errorResponse =
new ErrorResponse(errorReason, request.getRequestURL().toString());
return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
.body(errorResponse);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.ceos22.cgv_clone.api;
package com.ceos22.cgv_clone.api.controller;

import com.ceos22.cgv_clone.domain.dto.LoginReq;
import com.ceos22.cgv_clone.domain.dto.LoginRes;
import com.ceos22.cgv_clone.domain.dto.SignUpReq;
import com.ceos22.cgv_clone.api.dto.LoginRequest;
import com.ceos22.cgv_clone.api.dto.LoginResponse;
import com.ceos22.cgv_clone.api.dto.SignUpRequest;
import com.ceos22.cgv_clone.service.AuthService;
import com.ceos22.cgv_clone.service.LoginService;
import lombok.RequiredArgsConstructor;
Expand All @@ -19,12 +19,12 @@ public class AuthController {
private final LoginService loginService;

@PostMapping("/signup")
public void signUp(@RequestBody SignUpReq req) {
public void signUp(@RequestBody SignUpRequest req) {
authService.signUp(req);
}

@PostMapping("/login")
public LoginRes login(@RequestBody LoginReq req) {
public LoginResponse login(@RequestBody LoginRequest req) {
return loginService.login(req);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.ceos22.cgv_clone.api;
package com.ceos22.cgv_clone.api.controller;

import com.ceos22.cgv_clone.domain.dto.CinemaDto;
import com.ceos22.cgv_clone.service.FindCinemaService;
import com.ceos22.cgv_clone.api.dto.Cinema;
import com.ceos22.cgv_clone.service.cinema.FindCinemaService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -15,13 +15,13 @@ public class CinemaController {
private final FindCinemaService findCinemaService;

@GetMapping("/{id}")
public CinemaDto get(@PathVariable Long id) {
public Cinema get(@PathVariable Long id) {
return findCinemaService.getById(id);
}

@GetMapping
public Page<CinemaDto> list(Pageable pageable,
@RequestParam(required = false) String q) {
public Page<Cinema> list(Pageable pageable,
@RequestParam(required = false) String q) {
return (q == null || q.isBlank())
? findCinemaService.getPage(pageable)
: findCinemaService.searchByName(q, pageable);
Expand Down
Loading