diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5e4e8f3..0618cae 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -30,6 +30,9 @@ jobs: mysql user: 'test' mysql password: 'testPW' + - name: Start Redis + uses: supercharge/redis-github-action@1.6.0 + - name: Setup Gradle uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 @@ -52,6 +55,29 @@ jobs: spring.jpa.properties.hibernate.show_sql=true spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.properties.hibernate.format_sql=true + + + jwt.issuer=${{ secrets.JWT_ISSUER }} + jwt.secret_key=${{ secrets.JWT_SECRET_KEY }} + + # OAuth + spring.security.oauth2.client.registration.google.client-id=${{ secrets.GOOGLE_CLIENT_ID }} + spring.security.oauth2.client.registration.google.client-secret=${{ secrets.GOOGLE_CLIENT_SECRET }} + spring.security.oauth2.client.registration.google.scope=email,profile + + spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth + spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token + spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v3/userinfo + + #Redis + spring.data.redis.host=localhost + + spring.data.redis.port=6379 + + spring.data.redis.lettuce.pool.max-active=10 + spring.data.redis.lettuce.pool.max-idle=10 + spring.data.redis.lettuce.pool.min-idle=1 + spring.data.redis.lettuce.pool.max-wait=1000ms EOT shell: bash diff --git a/build.gradle b/build.gradle index d903a73..f592933 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,30 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //test + testImplementation 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + //spring security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + //jwt + implementation 'io.jsonwebtoken:jjwt:0.12.6' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' + + // Spring Security OAuth2 클라이언트 (구글/카카오 로그인 등) + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.data:spring-data-redis' + implementation 'org.apache.commons:commons-pool2' // 커넥션 풀 + implementation 'org.redisson:redisson-spring-boot-starter:3.25.2' } tasks.named('test') { diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/JwtPropertiesOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/JwtPropertiesOAuth.java new file mode 100644 index 0000000..e9f59fb --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/JwtPropertiesOAuth.java @@ -0,0 +1,13 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtPropertiesOAuth { + private String issuer; + private String secretKey; +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/OAuth2LoginSuccessHandlerOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/OAuth2LoginSuccessHandlerOAuth.java new file mode 100644 index 0000000..c3df6c7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/OAuth2LoginSuccessHandlerOAuth.java @@ -0,0 +1,53 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.quickpick.ureca.OAuth.auth.dto.UserLoginResponseOAuth; +import com.quickpick.ureca.OAuth.auth.service.RefreshTokenServiceOAuth; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import com.quickpick.ureca.OAuth.user.service.UserServiceOAuth; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandlerOAuth implements AuthenticationSuccessHandler { //OAuth 인증 성공시 jwt 발급 및 리디렉션 + private final TokenProviderOAuth tokenProvider; + private final UserServiceOAuth userService; + private final RefreshTokenServiceOAuth refreshTokenService; + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String email = oAuth2User.getAttribute("email"); + + // 사용자 DB에 저장 (없으면 새로 추가) + UserOAuth user = userService.findById(email) + .orElseGet(() -> userService.saveFromOAuth2(oAuth2User)); + + // JWT 발급 + String accessToken = tokenProvider.generateToken(user, Duration.ofHours(2)); // Access token + String refreshToken = tokenProvider.generateToken(user, Duration.ofDays(14)); // Refresh token (필요시 DB 저장) + + // Refresh token을 DB에 저장 + refreshTokenService.save(user.getUserId(), refreshToken); + + // 기존 로그인 응답 DTO 사용 + UserLoginResponseOAuth responseDto = new UserLoginResponseOAuth(accessToken, refreshToken); + + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.setStatus(HttpServletResponse.SC_OK); + objectMapper.writeValue(response.getWriter(), responseDto); + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/RedisConfigOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/RedisConfigOAuth.java new file mode 100644 index 0000000..12681aa --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/RedisConfigOAuth.java @@ -0,0 +1,20 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfigOAuth { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); // 토큰은 일반 문자열이므로 String 직렬화면 충분 + return template; + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenAuthenticationFilterOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenAuthenticationFilterOAuth.java new file mode 100644 index 0000000..bb60c10 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenAuthenticationFilterOAuth.java @@ -0,0 +1,68 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class TokenAuthenticationFilterOAuth extends OncePerRequestFilter { + private final TokenProviderOAuth tokenProvider; + private final RedisTemplate redisTemplate; + private final static String HEADER_AUTHORIZATION = "Authorization"; + private final static String BEARER = "Bearer "; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + //요청 헤더의 auth 키의 값 조회 + String authHeader = request.getHeader(HEADER_AUTHORIZATION); + String token = getAccessToken(authHeader); //접두사 제거해서 토큰 가져오기 + + try { + if (token != null) { + tokenProvider.validToken(token); //예외가 발생하면 catch문으로 + if (isBlacklisted(token)) { //블랙리스트에 있는 토큰이면 예외 발생 + throw new JwtException("Blacklisted token"); + } + //토큰이 유효하고 블랙리스트에 없다면 인증 정보 설정 + Authentication auth = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } catch (JwtException e) { + setErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + } + } + //에러 메세지 설정 메서드 + private void setErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + String responseBody = String.format("{\"error\": \"%s\"}", message); + response.getWriter().write(responseBody); + } + + private boolean isBlacklisted(String token) { + //redis 내 블랙리스트에 있는지 검사 + return redisTemplate.hasKey("blacklist:" + token); + } + + private String getAccessToken(String authHeader) { + if (authHeader != null && authHeader.startsWith(BEARER)) { + return authHeader.substring(BEARER.length()); + } + return null; + } + +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenProviderOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenProviderOAuth.java new file mode 100644 index 0000000..e0a2f7c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/TokenProviderOAuth.java @@ -0,0 +1,90 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.Set; + +@RequiredArgsConstructor +@Service +public class TokenProviderOAuth { + + private final JwtPropertiesOAuth jwtProperties; + + public String generateToken(UserOAuth user, Duration expiredAt) { + Date now = new Date(); + return makeToken(user, new Date( now.getTime() + expiredAt.toMillis())); + } // expriedAt 만큼의 유효기간을 가진 토큰 생성 + + public String makeToken(UserOAuth user, Date expiry) { + + return Jwts.builder() + .issuer(jwtProperties.getIssuer()) + .expiration(expiry) + .subject(user.getId()) + .claim("user_id", user.getUserId()) + .signWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8))) + .compact(); + } + + //토큰 검증 메서드 + public void validToken(String token) { + try{ + Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8))) + .build() + .parseSignedClaims(token); + } catch (SecurityException | MalformedJwtException e) { //서명이 불일치한 경우 / 구조가 손상된 경우 + throw new JwtException("Invalid JWT signature"); + } catch (ExpiredJwtException e) { //만료된 토큰인 경우 + throw new JwtException("JWT token expired"); + } catch (UnsupportedJwtException e) { //지원하지 않는 토큰인 경우 + throw new JwtException("Unsupported JWT token"); + } catch (IllegalArgumentException e) { //토큰이 아예 없거나 비정상적으로 전달된 경우? + throw new JwtException("JWT token is invalid"); + } + } + + public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); + Set authorities + = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); + + return new UsernamePasswordAuthenticationToken( + new org.springframework.security.core.userdetails.User( + claims.getSubject(), "", authorities) + , token + , authorities); + } + + public Long getUserId(String token) { + Claims claims = getClaims(token); + return claims.get("user_id", Long.class); + } + + //Claims 가져오기 + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8))) + .build() + .parseSignedClaims(token) + .getPayload(); //getBody()가 deprecated되어 이걸 쓸 것 + } + + //남은 토큰 유효시간 계산 + public long getRemainingValidity(String token) { + Claims claims = getClaims(token); + return claims.getExpiration().getTime() - System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/config/WebSecurityConfigOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/config/WebSecurityConfigOAuth.java new file mode 100644 index 0000000..47c3309 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/config/WebSecurityConfigOAuth.java @@ -0,0 +1,70 @@ +package com.quickpick.ureca.OAuth.auth.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfigOAuth { + + private final UserDetailsService userDetailsService; + private final TokenProviderOAuth tokenProvider; // TokenProvider 추가 + private final RedisTemplate redisTemplate; + + // Static 리소스는 인증 없이 접근 + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (webSecurity) -> webSecurity.ignoring() + .requestMatchers(new AntPathRequestMatcher("/static/**")); + } + + // Security Filter Chain + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, OAuth2LoginSuccessHandlerOAuth oAuth2LoginSuccessHandler) throws Exception { + return http + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //서버 세션 비활성화(jwt 사용하므로) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/login", "/signup", "/auth/token", "/oauth2/**").permitAll() // 로그인, 회원가입, 토큰 재발급, 소셜로그인은 인증 없이 접근 + .anyRequest().authenticated() // 그 외 요청은 인증 필요 + ) + .formLogin(AbstractHttpConfigurer::disable) //폼로그인 비활성화 + .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (API 서버일 경우) + .oauth2Login(oauth2 -> oauth2 + .successHandler(oAuth2LoginSuccessHandler) // 소셜로그인 설정 + ) + .addFilterBefore(new TokenAuthenticationFilterOAuth(tokenProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class) // JWT 필터 폼 로그인 필터 앞에 추가 + .build(); + } + + // AuthenticationManager 설정 (필요한가?) + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailsService userDetailsService) throws Exception { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(bCryptPasswordEncoder); + return new ProviderManager(authProvider); + } + + // BCryptPasswordEncoder 설정 + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/controller/AuthControllerOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/controller/AuthControllerOAuth.java new file mode 100644 index 0000000..5a34553 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/controller/AuthControllerOAuth.java @@ -0,0 +1,59 @@ +package com.quickpick.ureca.OAuth.auth.controller; + +import com.quickpick.ureca.OAuth.auth.dto.*; +import com.quickpick.ureca.OAuth.auth.service.AuthServiceOAuth; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + + + +@RestController +@RequiredArgsConstructor +public class AuthControllerOAuth { + private final AuthenticationManager authenticationManager; + private final AuthServiceOAuth authService; + + @PostMapping("/auth/login") //jwt를 이용한 자체 로그인 + public ResponseEntity login(@RequestBody UserLoginRequestOAuth request) { + try { + UserLoginResponseOAuth response = authService.login(request.getId(), request.getPassword()); + return ResponseEntity.ok(response); + } catch (UsernameNotFoundException | BadCredentialsException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Login failed: " + ex.getMessage()); + } catch (Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("An unexpected error occurred."); + } + } + + @PostMapping("/auth/logout") + public ResponseEntity logout(HttpServletRequest request) { + String accessToken = authService.extractToken(request); + authService.logout(accessToken); + return ResponseEntity.ok().build(); + } + + + @PostMapping("/auth/token") //jwt 엑세스 토큰 재발급 + public ResponseEntity createNewAccessToken( //ResponseEntity-> ResponseEntity로 수정 + @RequestBody CreateAccessTokenRequestOAuth request) { + try { + String newAccessToken + = authService.createNewAccessToken(request.getRefreshToken()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new CreateAccessTokenResponseOAuth(newAccessToken)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new CreateAccessTokenErrorResponseOAuth(e.getMessage())); + } + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/domain/RefreshTokenOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/domain/RefreshTokenOAuth.java new file mode 100644 index 0000000..b898b19 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/domain/RefreshTokenOAuth.java @@ -0,0 +1,32 @@ +package com.quickpick.ureca.OAuth.auth.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@Entity +public class RefreshTokenOAuth { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "refresh_id", updatable = false) + private Long refreshId; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + public RefreshTokenOAuth(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public RefreshTokenOAuth update(String newRefreshToken) { + this.refreshToken = newRefreshToken; + return this; + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenErrorResponseOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenErrorResponseOAuth.java new file mode 100644 index 0000000..9760e8b --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenErrorResponseOAuth.java @@ -0,0 +1,14 @@ +package com.quickpick.ureca.OAuth.auth.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateAccessTokenErrorResponseOAuth { //엑세스 토큰 생성 중 에러 발생 시 응답 dto + private String error; + + public CreateAccessTokenErrorResponseOAuth(String error) { + this.error = error; + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenRequestOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenRequestOAuth.java new file mode 100644 index 0000000..20278c4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenRequestOAuth.java @@ -0,0 +1,10 @@ +package com.quickpick.ureca.OAuth.auth.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateAccessTokenRequestOAuth { //엑세스 토큰 생성 요청 + private String refreshToken; +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenResponseOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenResponseOAuth.java new file mode 100644 index 0000000..4605e51 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/CreateAccessTokenResponseOAuth.java @@ -0,0 +1,10 @@ +package com.quickpick.ureca.OAuth.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CreateAccessTokenResponseOAuth { //엑세스 토큰 생성 요청에 대한 응답 + private String accessToken; +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginRequestOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginRequestOAuth.java new file mode 100644 index 0000000..ff2c88c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginRequestOAuth.java @@ -0,0 +1,11 @@ +package com.quickpick.ureca.OAuth.auth.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserLoginRequestOAuth { //로그인 요청 dto + private String id; // 사용자 ID + private String password; // 비밀번호 +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginResponseOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginResponseOAuth.java new file mode 100644 index 0000000..6708fda --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/dto/UserLoginResponseOAuth.java @@ -0,0 +1,11 @@ +package com.quickpick.ureca.OAuth.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserLoginResponseOAuth { //로그인 응답 dto + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/repository/RefreshTokenRepositoryOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/repository/RefreshTokenRepositoryOAuth.java new file mode 100644 index 0000000..849ef76 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/repository/RefreshTokenRepositoryOAuth.java @@ -0,0 +1,12 @@ +package com.quickpick.ureca.OAuth.auth.repository; + +import com.quickpick.ureca.OAuth.auth.domain.RefreshTokenOAuth; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepositoryOAuth extends JpaRepository { + Optional findByUserId(Long userId); + Optional findByRefreshToken(String refreshToken); + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/service/AuthServiceOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/service/AuthServiceOAuth.java new file mode 100644 index 0000000..c31f1f9 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/service/AuthServiceOAuth.java @@ -0,0 +1,92 @@ +package com.quickpick.ureca.OAuth.auth.service; + +import com.quickpick.ureca.OAuth.auth.config.TokenProviderOAuth; +import com.quickpick.ureca.OAuth.auth.domain.RefreshTokenOAuth; +import com.quickpick.ureca.OAuth.auth.dto.UserLoginResponseOAuth; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import com.quickpick.ureca.OAuth.user.service.UserServiceOAuth; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class AuthServiceOAuth { + + private final UserServiceOAuth userService; + private final TokenProviderOAuth tokenProvider; + private final RefreshTokenServiceOAuth refreshTokenService; + private final RedisTemplate redisTemplate; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + //jwt 로그인 + @Transactional + public UserLoginResponseOAuth login(String id, String password) { + UserOAuth user = userService.findById(id) + .orElseThrow(()-> new IllegalArgumentException("User not found")); + + if (!bCryptPasswordEncoder.matches(password, user.getPassword())) { //비밀번호 일치 검증 + throw new BadCredentialsException("Invalid password"); + } + + String accessToken = tokenProvider.generateToken(user, Duration.ofHours(2)); + String refreshToken = tokenProvider.generateToken(user, Duration.ofDays(14)); //로그인 성공 시 토큰 발급 + + refreshTokenService.save(user.getUserId(), refreshToken); + + return new UserLoginResponseOAuth(accessToken, refreshToken); + } + + // 토큰 추출 (Authorization 헤더에서 Bearer 제거) + public String extractToken(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); //Authorization값을 가지는 헤더 가져오기 + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); //앞에 Bearer 를 제거해 토큰 값만 가져오기 + } + throw new RuntimeException("Missing or invalid Authorization header"); + } + + //로그아웃 + @Transactional + public void logout(String accessToken) { + + //엑세스 토큰 블랙리스트 추가 + long expiration = tokenProvider.getRemainingValidity(accessToken); //엑세스 토큰의 남은 유효시간 계산 + redisTemplate.opsForValue().set("blacklist:" + accessToken, "logout", expiration, TimeUnit.MILLISECONDS); //남은 유효시간 만큼 블랙리스트에 넣기 + + //리프레시 토큰 삭제 + Long userId = tokenProvider.getUserId(accessToken); + refreshTokenService.deleteByUserId(userId); + } + + //리프레시 토큰을 이용한 엑세스 토큰 재발급 + @Transactional + public String createNewAccessToken(String refreshToken) { + //리프레시 토큰이 유효하지 않으면 에러 + try { + tokenProvider.validToken(refreshToken); + } catch (JwtException e) { + throw new JwtException(e.getMessage()); + } + + //저장된 리프레시 토큰 값과 달라도 에러 (아마 위에서 다 걸리지겠지만 혹시 모르니까) + RefreshTokenOAuth savedRefreshToken = refreshTokenService.findByRefreshToken(refreshToken); + if (savedRefreshToken == null) { + throw new JwtException("Invalid JWT RefreshToken"); + } + + //유효성이 검증되면 유저 정보 받아와서 새 엑세스 토큰 생성 + Long userId = savedRefreshToken.getUserId(); + UserOAuth user = userService.findByUserId(userId); + + return tokenProvider.generateToken(user, Duration.ofHours(2)); + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/auth/service/RefreshTokenServiceOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/auth/service/RefreshTokenServiceOAuth.java new file mode 100644 index 0000000..07da1e1 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/auth/service/RefreshTokenServiceOAuth.java @@ -0,0 +1,31 @@ +package com.quickpick.ureca.OAuth.auth.service; + +import com.quickpick.ureca.OAuth.auth.domain.RefreshTokenOAuth; +import com.quickpick.ureca.OAuth.auth.repository.RefreshTokenRepositoryOAuth; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class RefreshTokenServiceOAuth { + private final RefreshTokenRepositoryOAuth refreshTokenRepository; + + public RefreshTokenOAuth findByRefreshToken(String refreshToken) { + return refreshTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new IllegalArgumentException("Invalid refresh token")); + } + + //refresh 토큰 저장 (db 저장) + @Transactional + public void save(Long userId, String refreshToken) { + RefreshTokenOAuth token = new RefreshTokenOAuth(userId, refreshToken); + refreshTokenRepository.save(token); + } + + //refresh 토큰 삭제 + @Transactional + public void deleteByUserId(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + } +} diff --git a/src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java b/src/main/java/com/quickpick/ureca/OAuth/common/domain/BaseEntity.java similarity index 91% rename from src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java rename to src/main/java/com/quickpick/ureca/OAuth/common/domain/BaseEntity.java index e4ac893..fcf4813 100644 --- a/src/main/java/com/quickpick/ureca/common/domain/BaseEntity.java +++ b/src/main/java/com/quickpick/ureca/OAuth/common/domain/BaseEntity.java @@ -1,26 +1,26 @@ -package com.quickpick.ureca.common.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.MappedSuperclass; -import lombok.Getter; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -public abstract class BaseEntity { - @CreatedDate - @Column(length = 6, name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - @Column(length = 6, name = "updated_at") - private LocalDateTime updatedAt; - -} - +package com.quickpick.ureca.OAuth.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @CreatedDate + @Column(length = 6, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(length = 6, name = "updated_at") + private LocalDateTime updatedAt; + +} + diff --git a/src/main/java/com/quickpick/ureca/OAuth/reserve/controller/ReserveController.java b/src/main/java/com/quickpick/ureca/OAuth/reserve/controller/ReserveController.java new file mode 100644 index 0000000..830b59a --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/reserve/controller/ReserveController.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.OAuth.reserve.controller; + +public class ReserveController { +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/reserve/domain/Reserve.java b/src/main/java/com/quickpick/ureca/OAuth/reserve/domain/Reserve.java new file mode 100644 index 0000000..2e84f4f --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/reserve/domain/Reserve.java @@ -0,0 +1,28 @@ +package com.quickpick.ureca.OAuth.reserve.domain; + +import com.quickpick.ureca.OAuth.common.domain.BaseEntity; +import com.quickpick.ureca.OAuth.reserve.status.ReserveStatus; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table +@Entity +@Getter +@NoArgsConstructor +public class Reserve extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reserve_id") + private Long reserveId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserOAuth user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReserveStatus status; +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/OAuth/reserve/repository/ReserveRepository.java b/src/main/java/com/quickpick/ureca/OAuth/reserve/repository/ReserveRepository.java new file mode 100644 index 0000000..7e75899 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/reserve/repository/ReserveRepository.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.OAuth.reserve.repository; + +public class ReserveRepository { +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/reserve/service/ReserveService.java b/src/main/java/com/quickpick/ureca/OAuth/reserve/service/ReserveService.java new file mode 100644 index 0000000..ac7035c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/reserve/service/ReserveService.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.OAuth.reserve.service; + +public class ReserveService { +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/reserve/status/ReserveStatus.java b/src/main/java/com/quickpick/ureca/OAuth/reserve/status/ReserveStatus.java new file mode 100644 index 0000000..e4c4fff --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/reserve/status/ReserveStatus.java @@ -0,0 +1,6 @@ +package com.quickpick.ureca.OAuth.reserve.status; + +public enum ReserveStatus { + SUCCESS, + FAIL +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java b/src/main/java/com/quickpick/ureca/OAuth/ticket/domain/Ticket.java similarity index 82% rename from src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java rename to src/main/java/com/quickpick/ureca/OAuth/ticket/domain/Ticket.java index 2a0e3e9..c02041c 100644 --- a/src/main/java/com/quickpick/ureca/ticket/domain/Ticket.java +++ b/src/main/java/com/quickpick/ureca/OAuth/ticket/domain/Ticket.java @@ -1,7 +1,7 @@ -package com.quickpick.ureca.ticket.domain; +package com.quickpick.ureca.OAuth.ticket.domain; -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.userticket.domain.UserTicket; +import com.quickpick.ureca.OAuth.common.domain.BaseEntity; +import com.quickpick.ureca.OAuth.userticket.domain.UserTicket; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java b/src/main/java/com/quickpick/ureca/OAuth/ticket/repository/TicketRepository.java similarity index 56% rename from src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java rename to src/main/java/com/quickpick/ureca/OAuth/ticket/repository/TicketRepository.java index 34b9f66..f18450e 100644 --- a/src/main/java/com/quickpick/ureca/ticket/repository/TicketRepository.java +++ b/src/main/java/com/quickpick/ureca/OAuth/ticket/repository/TicketRepository.java @@ -1,6 +1,6 @@ -package com.quickpick.ureca.ticket.repository; +package com.quickpick.ureca.OAuth.ticket.repository; -import com.quickpick.ureca.ticket.domain.Ticket; +import com.quickpick.ureca.OAuth.ticket.domain.Ticket; import org.springframework.data.jpa.repository.JpaRepository; public interface TicketRepository extends JpaRepository { diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/controller/UserControllerOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/controller/UserControllerOAuth.java new file mode 100644 index 0000000..80012e9 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/controller/UserControllerOAuth.java @@ -0,0 +1,26 @@ +package com.quickpick.ureca.OAuth.user.controller; + +import com.quickpick.ureca.OAuth.user.dto.UserSignUpRequestOAuth; +import com.quickpick.ureca.OAuth.user.service.UserServiceOAuth; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class UserControllerOAuth { + + private final UserServiceOAuth userService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody UserSignUpRequestOAuth dto) { + userService.saveUser(dto); + return ResponseEntity.ok("회원가입 완료"); + } + + @GetMapping("/test") + public ResponseEntity test(){ + return ResponseEntity.ok("테스트 성공"); + } + +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/domain/UserOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/domain/UserOAuth.java new file mode 100644 index 0000000..cb10251 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/domain/UserOAuth.java @@ -0,0 +1,89 @@ +package com.quickpick.ureca.OAuth.user.domain; + +import com.quickpick.ureca.OAuth.common.domain.BaseEntity; +import com.quickpick.ureca.OAuth.userticket.domain.UserTicket; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +@Table +@Entity +@Getter +@NoArgsConstructor +public class UserOAuth extends BaseEntity implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; + + @Column(nullable = false, unique = true) + private String id; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer age; + + @Column(nullable = false) + private String gender; + + @Builder + public UserOAuth(String id, String password, String name, Integer age, String gender) { + this.id = id; + this.password = password; + this.name = name; + this.age = age; + this.gender = gender; + } + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTickets = new ArrayList<>(); + + @Override //사용자의 권한 목록 반환 + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("user")); + } + + @Override //사용자 id 반환 (고유한 이름) + public String getUsername() { + return id; + } + + @Override //사용자 비밀번호 반환 + public String getPassword() { + return password; + } + + @Override //계정이 만료 되었는지 확인 + public boolean isAccountNonExpired() { + return true; + } + + @Override //계정이 잠겼는지 확인 + public boolean isAccountNonLocked() { + return true; + } + + @Override //비밀번호 만료 확인 + public boolean isCredentialsNonExpired() { + return true; + } + + @Override //계정 사용여부 확인 + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/dto/UserSignUpRequestOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/dto/UserSignUpRequestOAuth.java new file mode 100644 index 0000000..1c4ac5b --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/dto/UserSignUpRequestOAuth.java @@ -0,0 +1,14 @@ +package com.quickpick.ureca.OAuth.user.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserSignUpRequestOAuth { //회원가입 요청 dto + private String id; // 사용자 ID + private String password; // 비밀번호 + private String name; // 이름 + private Integer age; // 나이 + private String gender; // 성별 ("M", "F") +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/repository/UserRepositoryOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/repository/UserRepositoryOAuth.java new file mode 100644 index 0000000..c3e3352 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/repository/UserRepositoryOAuth.java @@ -0,0 +1,11 @@ +package com.quickpick.ureca.OAuth.user.repository; + +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepositoryOAuth extends JpaRepository { + Optional findById(String id); //id(아이디)로 사용자 정보 가져오기 + Optional findByUserId(Long userId); //user_id(고유번호)로 사용자 정보 가져오기 +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/service/UserDetailServiceOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/service/UserDetailServiceOAuth.java new file mode 100644 index 0000000..8159173 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/service/UserDetailServiceOAuth.java @@ -0,0 +1,20 @@ +package com.quickpick.ureca.OAuth.user.service; + +import com.quickpick.ureca.OAuth.user.repository.UserRepositoryOAuth; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailServiceOAuth implements UserDetailsService { + + private final UserRepositoryOAuth userRepository; + + @Override + public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException { + return userRepository.findById(id).orElseThrow(() -> new UsernameNotFoundException(id)); + } +} diff --git a/src/main/java/com/quickpick/ureca/OAuth/user/service/UserServiceOAuth.java b/src/main/java/com/quickpick/ureca/OAuth/user/service/UserServiceOAuth.java new file mode 100644 index 0000000..7e14f2f --- /dev/null +++ b/src/main/java/com/quickpick/ureca/OAuth/user/service/UserServiceOAuth.java @@ -0,0 +1,59 @@ +package com.quickpick.ureca.OAuth.user.service; + +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import com.quickpick.ureca.OAuth.user.dto.UserSignUpRequestOAuth; +import com.quickpick.ureca.OAuth.user.repository.UserRepositoryOAuth; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserServiceOAuth { + + private final UserRepositoryOAuth userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + @Transactional + //자체 로그인 유저 저장 + public void saveUser(UserSignUpRequestOAuth dto) { + userRepository.save(UserOAuth.builder() + .id(dto.getId()) + .password(bCryptPasswordEncoder.encode(dto.getPassword())) + .name(dto.getName()) + .age(dto.getAge()) + .gender(dto.getGender()) + .build()); + } + + @Transactional + //구글 소셜 로그인 유저 저장 + public UserOAuth saveFromOAuth2(OAuth2User oAuth2User) { + String email = oAuth2User.getAttribute("email"); + String name = oAuth2User.getAttribute("name"); + + return userRepository.save(UserOAuth.builder() //age와 gender는 더미로 채우기 + .id(email) + .password("SOCIAL_USER") // 비밀번호는 사용하지 않으므로 더미 + .name(name != null ? name : "소셜사용자") + .age(0) // 추후 입력 받을 수 있도록 기본값(더미값 입력) + .gender("unknown") // "male" / "female"도 가능 (더미값 입력) + .build()); + } + + //user_id(고유 번호)로 유저 검색 + public UserOAuth findByUserId(Long userId) { + return userRepository.findByUserId(userId) + .orElseThrow(()-> new IllegalArgumentException("User not found")); + } + + //id(아이디)로 유저 검색 + public Optional findById(String id) { + return userRepository.findById(id); + //.orElseThrow(()-> new IllegalArgumentException("User not found")); -> 각 사용 위치에서 예외를 처리하도록 변경 + } +} diff --git a/src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java b/src/main/java/com/quickpick/ureca/OAuth/userticket/domain/UserTicket.java similarity index 70% rename from src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java rename to src/main/java/com/quickpick/ureca/OAuth/userticket/domain/UserTicket.java index 8825698..69e6d50 100644 --- a/src/main/java/com/quickpick/ureca/userticket/domain/UserTicket.java +++ b/src/main/java/com/quickpick/ureca/OAuth/userticket/domain/UserTicket.java @@ -1,7 +1,7 @@ -package com.quickpick.ureca.userticket.domain; +package com.quickpick.ureca.OAuth.userticket.domain; -import com.quickpick.ureca.ticket.domain.Ticket; -import com.quickpick.ureca.user.domain.User; +import com.quickpick.ureca.OAuth.ticket.domain.Ticket; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,7 +19,7 @@ public class UserTicket { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - private User user; + private UserOAuth user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "ticket_id") diff --git a/src/main/java/com/quickpick/ureca/UrecaApplication.java b/src/main/java/com/quickpick/ureca/UrecaApplication.java index 8be528e..d094861 100644 --- a/src/main/java/com/quickpick/ureca/UrecaApplication.java +++ b/src/main/java/com/quickpick/ureca/UrecaApplication.java @@ -1,13 +1,16 @@ package com.quickpick.ureca; +import com.quickpick.ureca.OAuth.auth.config.JwtPropertiesOAuth; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableScheduling +@EnableConfigurationProperties(JwtPropertiesOAuth.class) public class UrecaApplication { public static void main(String[] args) { diff --git a/src/main/java/com/quickpick/ureca/V2/common/domain/BaseEntityV2.java b/src/main/java/com/quickpick/ureca/V2/common/domain/BaseEntityV2.java new file mode 100644 index 0000000..ca886c7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/common/domain/BaseEntityV2.java @@ -0,0 +1,26 @@ +package com.quickpick.ureca.V2.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntityV2 { + @CreatedDate + @Column(length = 6, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(length = 6, name = "updated_at") + private LocalDateTime updatedAt; + +} + diff --git a/src/main/java/com/quickpick/ureca/V2/common/init/InitControllerV2.java b/src/main/java/com/quickpick/ureca/V2/common/init/InitControllerV2.java new file mode 100644 index 0000000..2883718 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/common/init/InitControllerV2.java @@ -0,0 +1,76 @@ +package com.quickpick.ureca.V2.common.init; + +import com.quickpick.ureca.V2.ticket.domain.TicketV2; +import com.quickpick.ureca.V2.ticket.repository.TicketRepositoryV2; +import com.quickpick.ureca.V2.user.domain.UserV2; +import com.quickpick.ureca.V2.user.repository.UserRepositoryV2; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/init2") +public class InitControllerV2 { + + private final TicketRepositoryV2 ticketRepository; + private final UserRepositoryV2 userRepository; + private final RedissonClient redissonClient; + + @PostMapping + public String initializeData( + @RequestParam(defaultValue = "3000") int ticketCount, + @RequestParam(defaultValue = "10000") int userCount, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime reserveDate + ) { + // DB 초기화 + ticketRepository.deleteAll(); + userRepository.deleteAll(); + + // 티켓 생성 및 저장 (ID 생성) + TicketV2 ticket = TicketV2.builder() + .name("테스트 티켓") + .quantity(ticketCount) + .startDate(startDate != null ? startDate : LocalDateTime.now().plusDays(1)) + .reserveDate(reserveDate != null ? reserveDate : LocalDateTime.now()) + .build(); + ticket = ticketRepository.save(ticket); // ID 보장 + + // Redis 키 초기화 + String redisStockKey = "ticket:stock:" + ticket.getTicketId(); + String redisUserSetKey = "ticket:users:" + ticket.getTicketId(); + + // 재고 수량 설정 + redissonClient.getBucket(redisStockKey, StringCodec.INSTANCE).set(String.valueOf(ticketCount)); + // 중복 예매 유저 Set 초기화 + redissonClient.getSet(redisUserSetKey, StringCodec.INSTANCE).delete(); + + // 유저 생성 + List users = new ArrayList<>(); + for (int i = 1; i <= userCount; i++) { + UserV2 user = UserV2.builder() + .id("user" + i) + .password("pw" + i) + .name("User" + i) + .age("20") + .gender("M") + .build(); + users.add(user); + } + userRepository.saveAll(users); + + return "초기화 완료: 티켓 1개(" + ticketCount + "개 수량), 유저 " + userCount + "명 생성"; + } + +} diff --git a/src/main/java/com/quickpick/ureca/V2/common/init/InitServiceV2.java b/src/main/java/com/quickpick/ureca/V2/common/init/InitServiceV2.java new file mode 100644 index 0000000..c6100c1 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/common/init/InitServiceV2.java @@ -0,0 +1,63 @@ +//package com.quickpick.ureca.V2.common.init; +// +//import com.quickpick.ureca.V2.ticket.domain.TicketV2; +//import com.quickpick.ureca.V2.ticket.repository.TicketRepositoryV2; +//import com.quickpick.ureca.V2.user.domain.UserV2; +//import com.quickpick.ureca.V2.user.repository.UserRepositoryV2; +//import lombok.RequiredArgsConstructor; +//import org.redisson.api.RedissonClient; +//import org.redisson.client.codec.StringCodec; +//import org.springframework.stereotype.Service; +// +//import java.time.LocalDateTime; +//import java.util.ArrayList; +//import java.util.List; +// +//@Service +//@RequiredArgsConstructor +//public class InitServiceV2 { +// +// private final TicketRepositoryV2 ticketRepository; +// private final UserRepositoryV2 userRepository; +// private final RedissonClient redissonClient; +// +// public String initialize(int ticketCount, int userCount, LocalDateTime startDate, LocalDateTime reserveDate) { +// // DB 초기화 +// ticketRepository.deleteAll(); +// userRepository.deleteAll(); +// +// // 티켓 생성 및 저장 (ID 생성) +// TicketV2 ticket = TicketV2.builder() +// .name("테스트 티켓") +// .quantity(ticketCount) +// .startDate(startDate != null ? startDate : LocalDateTime.now().plusDays(1)) +// .reserveDate(reserveDate != null ? reserveDate : LocalDateTime.now()) +// .build(); +// ticket = ticketRepository.save(ticket); // ID 보장 +// +// // Redis 키 초기화 +// String redisStockKey = "ticket:stock:" + ticket.getTicketId(); +// String redisUserSetKey = "ticket:users:" + ticket.getTicketId(); +// +// // 재고 수량 설정 +// redissonClient.getBucket(redisStockKey, StringCodec.INSTANCE).set(String.valueOf(ticketCount)); +// // 중복 예매 유저 Set 초기화 +// redissonClient.getSet(redisUserSetKey, StringCodec.INSTANCE).delete(); +// +// // 유저 생성 +// List users = new ArrayList<>(); +// for (int i = 1; i <= userCount; i++) { +// UserV2 user = UserV2.builder() +// .id("user" + i) +// .password("pw" + i) +// .name("User" + i) +// .age("20") +// .gender("M") +// .build(); +// users.add(user); +// } +// userRepository.saveAll(users); +// +// return "초기화 완료: 티켓 1개(" + ticketCount + "개 수량), 유저 " + userCount + "명 생성"; +// } +//} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/V2/common/init/InitTriggerV2.java b/src/main/java/com/quickpick/ureca/V2/common/init/InitTriggerV2.java new file mode 100644 index 0000000..4e1a7b4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/common/init/InitTriggerV2.java @@ -0,0 +1,26 @@ +//package com.quickpick.ureca.V2.common.init; +// +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.context.event.ApplicationReadyEvent; +//import org.springframework.context.ApplicationListener; +//import org.springframework.context.annotation.Profile; +//import org.springframework.stereotype.Component; +// +//import java.time.LocalDateTime; +// +//@Component +//@Profile("local") // 배포환경에서는 작동 안 하도록 +//@RequiredArgsConstructor +//public class InitTriggerV2 implements ApplicationListener { +// +// private final InitServiceV2 initService; +// +// @Override +// public void onApplicationEvent(ApplicationReadyEvent event) { +// LocalDateTime reserveDate = LocalDateTime.now(); +// LocalDateTime startDate = reserveDate.plusDays(1); +// // ticketCount, userCount는 필요에 따라 조정 +// initService.initialize(3000, 10000, startDate, reserveDate); +// } +// +//} diff --git a/src/main/java/com/quickpick/ureca/V2/reserve/controller/ReserveControllerV2.java b/src/main/java/com/quickpick/ureca/V2/reserve/controller/ReserveControllerV2.java new file mode 100644 index 0000000..47fe761 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/reserve/controller/ReserveControllerV2.java @@ -0,0 +1,11 @@ +package com.quickpick.ureca.V2.reserve.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2") +public class ReserveControllerV2 { +} diff --git a/src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java b/src/main/java/com/quickpick/ureca/V2/reserve/domain/ReserveV2.java similarity index 64% rename from src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java rename to src/main/java/com/quickpick/ureca/V2/reserve/domain/ReserveV2.java index b4ebcda..25a1cc1 100644 --- a/src/main/java/com/quickpick/ureca/reserve/domain/Reserve.java +++ b/src/main/java/com/quickpick/ureca/V2/reserve/domain/ReserveV2.java @@ -1,26 +1,23 @@ -package com.quickpick.ureca.reserve.domain; - -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.user.domain.User; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Table -@Entity -@Getter -@NoArgsConstructor -public class Reserve extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "reserve_id") - private Long reserveId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(nullable = false) - private String status; -} +package com.quickpick.ureca.V2.reserve.domain; + +import com.quickpick.ureca.V2.user.domain.UserV2; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class ReserveV2 { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reserve_id") + private Long reserveId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserV2 user; + + @Column(nullable = false) + private String status; +} diff --git a/src/main/java/com/quickpick/ureca/V2/reserve/repository/ReserveRepositoryV2.java b/src/main/java/com/quickpick/ureca/V2/reserve/repository/ReserveRepositoryV2.java new file mode 100644 index 0000000..8d09ca7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/reserve/repository/ReserveRepositoryV2.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.V2.reserve.repository; + +import com.quickpick.ureca.V2.reserve.domain.ReserveV2; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReserveRepositoryV2 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceImplV2.java b/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceImplV2.java new file mode 100644 index 0000000..e133cfa --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceImplV2.java @@ -0,0 +1,14 @@ +package com.quickpick.ureca.V2.reserve.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReserveServiceImplV2 implements ReserveServiceV2 { + + + +} diff --git a/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceV2.java b/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceV2.java new file mode 100644 index 0000000..2ba67e9 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/reserve/service/ReserveServiceV2.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.V2.reserve.service; + +public interface ReserveServiceV2 { +} diff --git a/src/main/java/com/quickpick/ureca/V2/ticket/controller/TicketControllerV2.java b/src/main/java/com/quickpick/ureca/V2/ticket/controller/TicketControllerV2.java new file mode 100644 index 0000000..012c74d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/ticket/controller/TicketControllerV2.java @@ -0,0 +1,26 @@ +package com.quickpick.ureca.V2.ticket.controller; + +import com.quickpick.ureca.V2.ticket.service.TicketServiceV2; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/tickets") +public class TicketControllerV2 { + + private final TicketServiceV2 ticketService; + + @PostMapping("/{ticketId}/order") + public ResponseEntity orderTicket(@PathVariable Long ticketId, @RequestParam Long userId) { + ticketService.orderTicket(ticketId, userId); + return ResponseEntity.ok("티켓 예매 성공"); + } + + @PostMapping("/{ticketId}/cancel") + public ResponseEntity cancelTicket(@PathVariable Long ticketId, @RequestParam Long userId) { + ticketService.cancelTicket(ticketId, userId); + return ResponseEntity.ok("티켓 예매 취소 성공"); + } +} diff --git a/src/main/java/com/quickpick/ureca/V2/ticket/domain/TicketV2.java b/src/main/java/com/quickpick/ureca/V2/ticket/domain/TicketV2.java new file mode 100644 index 0000000..be20b70 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/ticket/domain/TicketV2.java @@ -0,0 +1,49 @@ +package com.quickpick.ureca.V2.ticket.domain; +import com.quickpick.ureca.V2.common.domain.BaseEntityV2; +import com.quickpick.ureca.V2.userticket.domain.UserTicketV2; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "ticket") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TicketV2 extends BaseEntityV2 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ticket_id") + private Long ticketId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private LocalDateTime startDate; + + @Column(nullable = false) + private LocalDateTime reserveDate; + + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTickets = new ArrayList<>(); + +// @Version +// private Long version; + + public void setQuantity(int quantity) { + this.quantity = quantity; + } +} + diff --git a/src/main/java/com/quickpick/ureca/V2/ticket/repository/TicketRepositoryV2.java b/src/main/java/com/quickpick/ureca/V2/ticket/repository/TicketRepositoryV2.java new file mode 100644 index 0000000..bbc1169 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/ticket/repository/TicketRepositoryV2.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.V2.ticket.repository; + +import com.quickpick.ureca.V2.ticket.domain.TicketV2; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TicketRepositoryV2 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceImplV2.java b/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceImplV2.java new file mode 100644 index 0000000..ecac318 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceImplV2.java @@ -0,0 +1,393 @@ +package com.quickpick.ureca.V2.ticket.service; + +import com.quickpick.ureca.V2.ticket.domain.TicketV2; +import com.quickpick.ureca.V2.ticket.repository.TicketRepositoryV2; +import com.quickpick.ureca.V2.user.domain.UserV2; +import com.quickpick.ureca.V2.user.repository.UserRepositoryV2; +import com.quickpick.ureca.V2.userticket.domain.UserTicketV2; +import com.quickpick.ureca.V2.userticket.repository.UserTicketRepositoryV2; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RScript; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Transactional +@RequiredArgsConstructor +public class TicketServiceImplV2 implements TicketServiceV2 { + private final TicketRepositoryV2 ticketRepository; + private final UserRepositoryV2 userRepository; + private final UserTicketRepositoryV2 userTicketRepository; + private final RedissonClient redissonClient; + + // Lua 스크립트 및 SHA1 캐싱용 변수 + private String reserveLuaSha; + private String rollbackLuaSha; + private final Map ticketCache = new ConcurrentHashMap<>(); + + /** + * TEST 2 + */ +// @Override +// @Transactional +// public void orderTicket(Long ticketId, Long userId) { +// int retry = 3; // 재시도 횟수 +// while (retry-- > 0) { +// try { +// processOrder(ticketId, userId); +// return; // 성공하면 종료 +// } catch (ObjectOptimisticLockingFailureException e) { +// if (retry == 0) throw e; +// } +// } +// } +// +// private void processOrder(Long ticketId, Long userId) { +// TicketV2 ticket = ticketRepository.findById(ticketId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); +// +// if (ticket.getQuantity() <= 0) { +// throw new RuntimeException("매진된 티켓입니다."); +// } +// +// UserV2 user = userRepository.findById(userId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); +// +// if (userTicketRepository.existsByUserAndTicket(user, ticket)) { +// throw new RuntimeException("이미 예매한 티켓입니다."); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// ticketRepository.save(ticket); // save() 시 OptimisticLock 체크 발생 +// +// UserTicketV2 userTicket = new UserTicketV2(user, ticket); +// userTicketRepository.save(userTicket); +// } + + + /** + * TEST3 + */ +// @Override +// @Transactional +// public void orderTicket(Long ticketId, Long userId) { +// RLock lock = redissonClient.getLock("ticketLock:" + ticketId); +// +// boolean isLocked = false; +// try { +// // 최대 2초 대기, 5초 안에 락 자동 해제 +// isLocked = lock.tryLock(2, 5, TimeUnit.SECONDS); +// +// if (!isLocked) { +// throw new RuntimeException("잠시 후 다시 시도해주세요."); +// } +// +// Ticket ticket = ticketRepository.findById(ticketId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); +// if (ticket.getQuantity() <= 0) { +// throw new RuntimeException("매진된 티켓입니다."); +// } +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); +// if (userTicketRepository.existsByUserAndTicket(user, ticket)) { +// throw new RuntimeException("이미 예매한 티켓입니다."); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// ticketRepository.save(ticket); +// +// UserTicket userTicket = new UserTicket(user, ticket); +// userTicketRepository.save(userTicket); +// +// } catch (InterruptedException e) { +// throw new RuntimeException("락 획득 실패", e); +// } finally { +// if (isLocked) { +// lock.unlock(); +// } +// } +// } + + /** + * TEST4 + */ +// @Override +// public void orderTicket(Long ticketId, Long userId) { +// RLock lock = redissonClient.getLock("ticketLock:" + ticketId); +// boolean isLocked = false; +// Ticket ticket; +// +// try { +// // 최대 2초 대기 후 락 획득 시도, 락은 5초 후 자동 해제 +// isLocked = lock.tryLock(2, 5, TimeUnit.SECONDS); +// if (!isLocked) { +// throw new RuntimeException("잠시 후 다시 시도해주세요."); +// } +// +// // 락 안에서: 티켓 조회 및 재고 감소 +// ticket = ticketRepository.findById(ticketId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); +// +// if (ticket.getQuantity() <= 0) { +// throw new RuntimeException("매진된 티켓입니다."); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// ticketRepository.save(ticket); +// +// } catch (InterruptedException e) { +// throw new RuntimeException("락 획득 실패", e); +// } finally { +// if (isLocked && lock.isHeldByCurrentThread()) { +// lock.unlock(); +// } +// } +// +// // 락 외부: 유저 조회 및 예매 중복 검사, 예매 저장 +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); +// +// if (userTicketRepository.existsByUserAndTicket(user, ticket)) { +// throw new RuntimeException("이미 예매한 티켓입니다."); +// } +// +// UserTicket userTicket = new UserTicket(user, ticket); +// userTicketRepository.save(userTicket); +// } + + + /** + * TEST5 + */ +// @Override +// public void orderTicket(Long ticketId, Long userId) { +// String stockKey = "ticket:stock:" + ticketId; +// +// String luaScript = +// "local stock = redis.call('GET', KEYS[1])\n" + +// "if not stock then return -1 end\n" + +// "stock = tonumber(stock)\n" + +// "if stock <= 0 then return -1 end\n" + +// "redis.call('DECR', KEYS[1])\n" + +// "return 1"; +// +// Long result; +// try { +// result = redissonClient.getScript().eval( +// RScript.Mode.READ_WRITE, +// luaScript, +// RScript.ReturnType.INTEGER, +// Collections.singletonList(stockKey) +// ); +// } catch (Exception e) { +// throw new RuntimeException("Lua 실행 실패: " + e.getMessage(), e); +// } +// +// if (result == null || result != 1L) { +// throw new RuntimeException("매진된 티켓입니다."); +// } +// +// // 이후 DB에서 ticket, user 조회 및 중복 예매 확인 → UserTicket 저장 +// Ticket ticket = ticketRepository.findById(ticketId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); +// if (userTicketRepository.existsByUserAndTicket(user, ticket)) { +// throw new RuntimeException("이미 예매한 티켓입니다."); +// } +// UserTicket userTicket = new UserTicket(user, ticket); +// userTicketRepository.save(userTicket); +// } + + /** + * TEST6 + */ +// @Override +// @Transactional +// public void orderTicket(Long ticketId, Long userId) { +// String stockKey = "ticket:stock:" + ticketId; +// String userSetKey = "ticket:users:" + ticketId; +// +// String luaScript = +// "local stock = redis.call('GET', KEYS[1])\n" + +// "if not stock then return -1 end\n" + +// "stock = tonumber(stock)\n" + +// "if stock <= 0 then return -1 end\n" + +// "local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1])\n" + +// "if exists == 1 then return -2 end\n" + +// "redis.call('DECR', KEYS[1])\n" + +// "redis.call('SADD', KEYS[2], ARGV[1])\n" + +// "return 1"; +// +// Long result; +// try { +// result = redissonClient.getScript(StringCodec.INSTANCE).eval( +// RScript.Mode.READ_WRITE, +// luaScript, +// RScript.ReturnType.INTEGER, +// Arrays.asList(stockKey, userSetKey), +// userId.toString() +// ); +// } catch (Exception e) { +// throw new RuntimeException("Lua 실행 실패: " + e.getMessage(), e); +// } +// +// if (result == -1L) { +// throw new RuntimeException("매진된 티켓입니다."); +// } +// if (result == -2L) { +// throw new RuntimeException("이미 예매한 유저입니다."); +// } +// +// try { +// Ticket ticket = ticketRepository.findById(ticketId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); +// +// UserTicket userTicket = new UserTicket(user, ticket); +// userTicketRepository.save(userTicket); +// } catch (Exception e) { +// // Redis 재고 복구 +// redissonClient.getBucket(stockKey, StringCodec.INSTANCE).set( +// String.valueOf( +// Integer.parseInt((String) redissonClient.getBucket(stockKey, StringCodec.INSTANCE).get()) + 1 +// ) +// ); +// redissonClient.getSet(userSetKey, StringCodec.INSTANCE).remove(userId.toString()); +// throw new RuntimeException("DB 저장 중 오류 발생, Redis 재고 복구", e); +// } +// } + + /** + * TEST7 + */ + @PostConstruct + public void loadLuaScripts() { + // 예약 처리 Lua + String reserveLua = """ + local stock = redis.call('GET', KEYS[1]) + if not stock then return -1 end + stock = tonumber(stock) + if stock <= 0 then return -1 end + local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1]) + if exists == 1 then return -2 end + redis.call('DECR', KEYS[1]) + redis.call('SADD', KEYS[2], ARGV[1]) + return 1 + """; + + // 롤백 처리 Lua + String rollbackLua = """ + redis.call('INCR', KEYS[1]) + redis.call('SREM', KEYS[2], ARGV[1]) + return 1 + """; + + RScript script = redissonClient.getScript(StringCodec.INSTANCE); + reserveLuaSha = script.scriptLoad(reserveLua); + rollbackLuaSha = script.scriptLoad(rollbackLua); + } + +// @Transactional + public void orderTicket(Long ticketId, Long userId) { + String stockKey = "ticket:stock:" + ticketId; + String userSetKey = "ticket:users:" + ticketId; + + Long result; + try { + result = redissonClient.getScript(StringCodec.INSTANCE).evalSha( + RScript.Mode.READ_WRITE, + reserveLuaSha, + RScript.ReturnType.INTEGER, + Arrays.asList(stockKey, userSetKey), + userId.toString() + ); + } catch (Exception e) { + throw new RuntimeException("Lua 실행 실패: " + e.getMessage(), e); + } + + if (result == -1L) { + throw new RuntimeException("매진된 티켓입니다."); + } + if (result == -2L) { + throw new RuntimeException("이미 예매한 유저입니다."); + } + + try { + // 🔽 캐시된 Ticket 사용 + TicketV2 ticket = ticketCache.computeIfAbsent(ticketId, id -> + ticketRepository.findById(id) + .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")) + ); + + UserV2 user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); + + UserTicketV2 userTicket = new UserTicketV2(user, ticket); + userTicketRepository.save(userTicket); + } catch (Exception e) { + // Redis 복구 (Lua) + redissonClient.getScript(StringCodec.INSTANCE).evalSha( + RScript.Mode.READ_WRITE, + rollbackLuaSha, + RScript.ReturnType.INTEGER, + Arrays.asList(stockKey, userSetKey), + userId.toString() + ); + throw new RuntimeException("DB 저장 중 오류 발생, Redis 복구 수행", e); + } + } + + @Override + @Transactional + public void cancelTicket(Long ticketId, Long userId) { + String stockKey = "ticket:stock:" + ticketId; + String userSetKey = "ticket:users:" + ticketId; + + String luaScript = + "local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1])\n" + + "if exists == 0 then return -1 end\n" + // 예매 기록 없음 + "redis.call('SREM', KEYS[2], ARGV[1])\n" + + "redis.call('INCR', KEYS[1])\n" + + "return 1"; + + Long result; + try { + result = redissonClient.getScript(StringCodec.INSTANCE).eval( + RScript.Mode.READ_WRITE, + luaScript, + RScript.ReturnType.INTEGER, + Arrays.asList(stockKey, userSetKey), + userId.toString() + ); + } catch (Exception e) { + throw new RuntimeException("Lua 실행 실패: " + e.getMessage(), e); + } + + if (result == -1L) { + throw new RuntimeException("예매 기록이 없습니다."); + } + + // Redis에서는 성공적으로 복구됐으므로, DB에서도 이력 삭제 + TicketV2 ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다.")); + UserV2 user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 유저입니다.")); + + UserTicketV2 userTicket = userTicketRepository.findByUserAndTicket(user, ticket) + .orElseThrow(() -> new RuntimeException("예매 기록이 없습니다.")); + userTicketRepository.delete(userTicket); + } + + +} diff --git a/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceV2.java b/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceV2.java new file mode 100644 index 0000000..da345f8 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/ticket/service/TicketServiceV2.java @@ -0,0 +1,6 @@ +package com.quickpick.ureca.V2.ticket.service; + +public interface TicketServiceV2 { + void orderTicket(Long ticketId, Long userId); + void cancelTicket(Long ticketId, Long userId); +} diff --git a/src/main/java/com/quickpick/ureca/V2/user/controller/UserControllerV2.java b/src/main/java/com/quickpick/ureca/V2/user/controller/UserControllerV2.java new file mode 100644 index 0000000..7a6bc70 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/user/controller/UserControllerV2.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.V2.user.controller; + +public class UserControllerV2 { +} diff --git a/src/main/java/com/quickpick/ureca/V2/user/domain/UserV2.java b/src/main/java/com/quickpick/ureca/V2/user/domain/UserV2.java new file mode 100644 index 0000000..ab8e864 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/user/domain/UserV2.java @@ -0,0 +1,45 @@ +package com.quickpick.ureca.V2.user.domain; + +import com.quickpick.ureca.V2.common.domain.BaseEntityV2; +import com.quickpick.ureca.V2.userticket.domain.UserTicketV2; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Table +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserV2 extends BaseEntityV2 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; + + @Column(nullable = false) + private String id; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String age; + + @Column(nullable = false) + private String gender; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTickets = new ArrayList<>(); + +} diff --git a/src/main/java/com/quickpick/ureca/V2/user/repository/UserRepositoryV2.java b/src/main/java/com/quickpick/ureca/V2/user/repository/UserRepositoryV2.java new file mode 100644 index 0000000..f7f73ca --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/user/repository/UserRepositoryV2.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.V2.user.repository; + +import com.quickpick.ureca.V2.user.domain.UserV2; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepositoryV2 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/V2/user/service/UserServiceV2.java b/src/main/java/com/quickpick/ureca/V2/user/service/UserServiceV2.java new file mode 100644 index 0000000..f3170b1 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/user/service/UserServiceV2.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.V2.user.service; + +public class UserServiceV2 { +} diff --git a/src/main/java/com/quickpick/ureca/V2/userticket/domain/UserTicketV2.java b/src/main/java/com/quickpick/ureca/V2/userticket/domain/UserTicketV2.java new file mode 100644 index 0000000..46a95ea --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/userticket/domain/UserTicketV2.java @@ -0,0 +1,33 @@ +package com.quickpick.ureca.V2.userticket.domain; + +import com.quickpick.ureca.V2.ticket.domain.TicketV2; +import com.quickpick.ureca.V2.user.domain.UserV2; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table +@Getter +@NoArgsConstructor +public class UserTicketV2 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_ticket_id") + private Long userTicketId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private UserV2 user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id") + private TicketV2 ticket; + + public UserTicketV2(UserV2 user, TicketV2 ticket) { + this.user = user; + this.ticket = ticket; + } + +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/V2/userticket/repository/UserTicketRepositoryV2.java b/src/main/java/com/quickpick/ureca/V2/userticket/repository/UserTicketRepositoryV2.java new file mode 100644 index 0000000..36ccbd9 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/V2/userticket/repository/UserTicketRepositoryV2.java @@ -0,0 +1,13 @@ +package com.quickpick.ureca.V2.userticket.repository; + +import com.quickpick.ureca.V2.ticket.domain.TicketV2; +import com.quickpick.ureca.V2.user.domain.UserV2; +import com.quickpick.ureca.V2.userticket.domain.UserTicketV2; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserTicketRepositoryV2 extends JpaRepository { + boolean existsByUserAndTicket(UserV2 user, TicketV2 ticket); + Optional findByUserAndTicket(UserV2 user, TicketV2 ticket); +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java b/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java deleted file mode 100644 index 7b3818a..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/controller/ReserveController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.controller; - -public class ReserveController { -} diff --git a/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java b/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java deleted file mode 100644 index d9dfba9..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/repository/ReserveRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.repository; - -public class ReserveRepository { -} diff --git a/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java b/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java deleted file mode 100644 index 28c25c2..0000000 --- a/src/main/java/com/quickpick/ureca/reserve/service/ReserveService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.reserve.service; - -public class ReserveService { -} diff --git a/src/main/java/com/quickpick/ureca/user/controller/UserController.java b/src/main/java/com/quickpick/ureca/user/controller/UserController.java deleted file mode 100644 index 5ea1b6a..0000000 --- a/src/main/java/com/quickpick/ureca/user/controller/UserController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.controller; - -public class UserController { -} diff --git a/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java b/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java deleted file mode 100644 index 50abb0e..0000000 --- a/src/main/java/com/quickpick/ureca/user/repository/UserRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.repository; - -public class UserRepository { -} diff --git a/src/main/java/com/quickpick/ureca/user/service/UserService.java b/src/main/java/com/quickpick/ureca/user/service/UserService.java deleted file mode 100644 index 972e2b1..0000000 --- a/src/main/java/com/quickpick/ureca/user/service/UserService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.quickpick.ureca.user.service; - -public class UserService { -} diff --git a/src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java b/src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java new file mode 100644 index 0000000..b76a7f6 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/domain/BaseEntity.java @@ -0,0 +1,26 @@ +package com.quickpick.ureca.v1.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @CreatedDate + @Column(length = 6, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(length = 6, name = "updated_at") + private LocalDateTime updatedAt; + +} + diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java new file mode 100644 index 0000000..e684db8 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitController.java @@ -0,0 +1,25 @@ +package com.quickpick.ureca.v1.common.init; + +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/init") +public class InitController { + + private final InitService initService; + + @PostMapping + public String initializePost( + @RequestParam(defaultValue = "1000") int ticketCount, + @RequestParam(defaultValue = "10000") int userCount, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime reserveDate + ) { + return initService.initialize(ticketCount, userCount, startDate, reserveDate); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java new file mode 100644 index 0000000..f5d3910 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitService.java @@ -0,0 +1,49 @@ +package com.quickpick.ureca.v1.common.init; + +import com.quickpick.ureca.v1.ticket.domain.TicketV1; +import com.quickpick.ureca.v1.ticket.repository.TicketRepositoryV1; +import com.quickpick.ureca.v1.user.domain.User; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class InitService { + + private final TicketRepositoryV1 ticketRepository; + private final UserRepositoryV1 userRepositoryV1; + + public String initialize(int ticketCount, int userCount, LocalDateTime startDate, LocalDateTime reserveDate) { + ticketRepository.deleteAll(); + userRepositoryV1.deleteAll(); + + TicketV1 ticket = TicketV1.builder() + .name("테스트 티켓") + .quantity(ticketCount) + .startDate(startDate != null ? startDate : LocalDateTime.now().plusDays(1)) + .reserveDate(reserveDate != null ? reserveDate : LocalDateTime.now()) + .build(); + ticketRepository.save(ticket); + + List users = new ArrayList<>(); + for (int i = 1; i <= userCount; i++) { + User user = User.builder() + .id("user" + i) + .password("pw" + i) + .name("User" + i) + .age("20") + .gender("M") + .build(); + users.add(user); + } + userRepositoryV1.saveAll(users); + + return "초기화 완료: 티켓 1개(" + ticketCount + "개 수량), 유저 " + userCount + "명 생성"; + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java b/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java new file mode 100644 index 0000000..942a72b --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/common/init/InitTrigger.java @@ -0,0 +1,26 @@ +//package com.quickpick.ureca.common.init; +// +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.context.event.ApplicationReadyEvent; +//import org.springframework.context.ApplicationListener; +//import org.springframework.context.annotation.Profile; +//import org.springframework.stereotype.Component; +// +//import java.time.LocalDateTime; +// +//@Component +//@Profile("local") // 배포환경에서는 작동 안 하도록 +//@RequiredArgsConstructor +//public class InitTrigger implements ApplicationListener { +// +// private final InitService initService; +// +// @Override +// public void onApplicationEvent(ApplicationReadyEvent event) { +// LocalDateTime reserveDate = LocalDateTime.now(); +// LocalDateTime startDate = reserveDate.plusDays(1); +// // ticketCount, userCount는 필요에 따라 조정 +// initService.initialize(3000, 10000, startDate, reserveDate); +// } +// +//} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java new file mode 100644 index 0000000..ade37c6 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/controller/ReserveControllerV1.java @@ -0,0 +1,53 @@ +package com.quickpick.ureca.v1.reserve.controller; + +import com.quickpick.ureca.v1.reserve.service.ReserveServiceV1; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/test-reserve") +@Slf4j +public class ReserveControllerV1 { + + private final ReserveServiceV1 reserveServiceV1; + private final UserRepositoryV1 userRepositoryV1; + + public ReserveControllerV1(ReserveServiceV1 reserveServiceV1, UserRepositoryV1 userRepositoryV1) { + this.reserveServiceV1 = reserveServiceV1; + this.userRepositoryV1 = userRepositoryV1; + } + + @PostMapping("/reserve") + public ResponseEntity reserve(@RequestParam Long userId, @RequestParam Long ticketId) { + try { + reserveServiceV1.reserveTicket(userId, ticketId); + return ResponseEntity.ok("예약 성공"); + } catch (Exception e) { + log.error("예약 실패: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("예약 실패: " + e.getMessage()); + } + } + + // open-in-view + Fetch-Join + DTO +// @PostMapping +// public ResponseEntity reserve(@RequestParam Long userId, @RequestParam Long ticketId) { +// try { +// Ticket ticket = reserveServiceV1.reserveTicket(userId, ticketId); +// User user = userRepository.findById(userId).orElseThrow(); +// return ResponseEntity.ok(TicketReserveResponse.of(ticket, user)); +// } catch (Exception e) { +// log.error("예약 실패: {}", e.getMessage()); +// return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); +// } +// } + + @PostMapping("/cancel") + public ResponseEntity cancelReservation(@RequestParam Long userId, @RequestParam Long ticketId) { + log.info("{}의 티켓 취소 요청", userId); + reserveServiceV1.cancelReservation(userId, ticketId); + return ResponseEntity.ok("예약이 성공적으로 취소되었습니다."); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java new file mode 100644 index 0000000..1aa8034 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/domain/ReserveV1.java @@ -0,0 +1,28 @@ +package com.quickpick.ureca.v1.reserve.domain; + +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.reserve.status.ReserveStatusV1; +import com.quickpick.ureca.v1.user.domain.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table +@Entity +@Getter +@NoArgsConstructor +public class ReserveV1 extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reserve_id") + private Long reserveId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReserveStatusV1 status; +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java new file mode 100644 index 0000000..a4d898b --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/dto/TicketReserveResponseV1.java @@ -0,0 +1,20 @@ +package com.quickpick.ureca.v1.reserve.dto; + +import com.quickpick.ureca.v1.ticket.domain.TicketV1; +import com.quickpick.ureca.v1.user.domain.User; + +public record TicketReserveResponseV1( + Long ticketId, + String ticketName, + int remainingQuantity, + String reservedByUsername +) { + public static TicketReserveResponseV1 of(TicketV1 ticket, User user) { + return new TicketReserveResponseV1( + ticket.getTicketId(), + ticket.getName(), + ticket.getQuantity(), + user.getId() + ); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java new file mode 100644 index 0000000..b87dd3d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/repository/ReserveRepositoryV1.java @@ -0,0 +1,9 @@ +package com.quickpick.ureca.v1.reserve.repository; + +import com.quickpick.ureca.v1.reserve.domain.ReserveV1; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReserveRepositoryV1 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java new file mode 100644 index 0000000..e98c280 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/service/ReserveServiceV1.java @@ -0,0 +1,235 @@ +package com.quickpick.ureca.v1.reserve.service; + +import com.quickpick.ureca.v1.ticket.cache.TicketSoldOutCacheV1; +import com.quickpick.ureca.v1.ticket.domain.TicketV1; +import com.quickpick.ureca.v1.ticket.repository.TicketRepositoryV1; +import com.quickpick.ureca.v1.user.domain.User; +import com.quickpick.ureca.v1.user.repository.UserRepositoryV1; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import com.quickpick.ureca.v1.userticket.repository.UserTicketRepositoryV1; +import com.quickpick.ureca.v1.userticket.repository.UserTicketShardingRepositoryV1; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class ReserveServiceV1 { + + @Autowired + private TicketRepositoryV1 ticketRepositoryV1; + + @Autowired + private UserRepositoryV1 userRepositoryV1; + + @Autowired + private UserTicketRepositoryV1 userTicketRepositoryV1; + + @Autowired + private UserTicketShardingRepositoryV1 userTicketShardingRepositoryV1; + + @Autowired + private TicketSoldOutCacheV1 ticketSoldOutCacheV1; + + // 1. +// // 티켓 예약 메서드 (락 X) Average : 586, Throughput : 17.5/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// // 티켓과 사용자 가져오기 +// TicketV1 ticket = ticketRepositoryV1.findById(ticketId).orElseThrow(() -> new RuntimeException("티켓을 찾을 수 없습니다.")); +// UserV1 user = userRepositoryV1.findById(userId).orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); +// +// // 티켓 재고가 없으면 예외 발생 +// if (ticket.getQuantity() <= 0) { +// throw new RuntimeException("매진되었습니다."); +// } +// +// // 티켓 재고 감소 +// System.out.println("감소!"); +// ticket.decreaseCount(); +// ticketRepositoryV1.save(ticket); // 재고 감소 후 저장 +// +// // 예약 정보 저장 +// UserTicketV1 userTicket = new UserTicketV1(user, ticket); +// userTicketRepositoryV1.save(userTicket); +// } + + + // 2. + // 티켓 예약 메서드 (비관적 락) - Pessimistic Lock Average : 6691, Throughput : 419.9/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// System.out.println("Reserve Ticket"); +// Ticket ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) +// .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); +// +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// } + + // 3. + // 티켓 예약 메서드 (비관적 락 + open-in-view) True Average : 14836, Throughput : 263.9/sec + // 티켓 예약 메서드 (비관적 락 + open-in-view) False Average : 13589, Throughput : 275.8/sec + + // 티켓 예약 메서드 (비관적 락 + open-in-view + 네이티브 쿼리) True Average : 15919, Throughput : 244.7/sec + // 티켓 예약 메서드 (비관적 락 + open-in-view + 네이티브 쿼리) False Average : 14961, Throughput : 262.3/sec + + // 티켓 예약 메서드 (open-in-view + FetchJoin + DTO) False Average : 69005, Throughput : 64.9/sec + +// @Transactional +// public Ticket reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// // user는 fetch join 하지 않았으므로 별도로 조회 +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// // fetch join으로 모든 필요한 정보 로딩 +// Ticket ticket = ticketRepositoryV1.findByIdForUpdateNative(ticketId); +// +// if (userTicketRepository.existsByUser_UserIdAndTicket_TicketId(userId, ticketId)) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return ticket; // 중복이면 insert 안 하고 그냥 리턴 +// } +// +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// return ticket; +// } + + + // 4. + // 티켓 예약 메서드 (비관적 락 + 중복방지 + 인덱스 + 네이티브 쿼리) Average : 9734, Throughput : 339.2/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// System.out.println("Reserve Ticket"); +// Ticket ticket = ticketRepositoryV1.findByIdForUpdateNative(ticketId); +// +// if (userTicketRepository.existsUserTicketRaw(userId, ticketId) != null) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return; +// } +// +// // 이 부분이 Redis를 사용하면 더 효율적으로 변경 가능 +// if (ticket.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// ticket.setQuantity(ticket.getQuantity() - 1); +// userTicketRepository.save(new UserTicket(user, ticket)); +// } + + // 5. + // 티켓 예약 메서드 (비관적 락 + 중복방지 + open-in-view(True) + 인덱스 + Projection + 네이티브 쿼리) Average : 12094, Throughput : 339.1/sec + // 티켓 예약 메서드 (비관적 락 + 중복방지 + open-in-view(False) + Projection + 네이티브 쿼리) Average : 13033, Throughput : 293.7/sec +// @Transactional +// public void reserveTicket(Long userId, Long ticketId) { +// +// log.info(">>> reserveTicket called: userId = {}, ticketId = {}", userId, ticketId); +// +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("User not found")); +// +// // quantity만 조회하는 Projection으로 변경 +// TicketQuantityProjection ticketProjection = ticketRepositoryV1.findQuantityForUpdate(ticketId); +// if (ticketProjection == null) { +// throw new IllegalArgumentException("Ticket not found"); +// } +// +// if (userTicketRepository.existsUserTicketRaw(userId, ticketId) != null) { +// log.warn("이미 예약한 유저입니다. userId={}, ticketId={}", userId, ticketId); +// return; +// } +// +// if (ticketProjection.getQuantity() <= 0) { +// throw new IllegalStateException("Ticket out of stock"); +// } +// +// // 수량 감소는 직접 쿼리로 처리하거나, 엔티티 조회 후 업데이트 필요 +// ticketRepositoryV1.decreaseQuantity(ticketId); // 이 메서드는 아래에 작성 +// userTicketRepository.save(new UserTicket(user, ticketRepositoryV1.getReferenceById(ticketId))); +// +// } + + + // 티켓 예약 메서드 (비관적 락 + 중복방지 + In-Memory 캐시 + Sharding + 네이티브 쿼리) Average : 13016, Throughput : 474.7/sec + @Transactional + public void reserveTicket(Long userId, Long ticketId) { + log.info("Reserving ticket: userId = {}, ticketId = {}", userId, ticketId); + + if (ticketSoldOutCacheV1.isSoldOut(ticketId)) { + throw new IllegalStateException("이미 매진된 티켓입니다."); + } + + User user = userRepositoryV1.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (userTicketShardingRepositoryV1.exists(userId, ticketId)) { + throw new IllegalStateException("이미 예약함"); + } + + TicketV1 ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + if (ticket.getQuantity() <= 0) { + ticketSoldOutCacheV1.markSoldOut(ticketId); // 캐시 반영 + throw new IllegalStateException("재고 없음"); + } + + ticket.setQuantity(ticket.getQuantity() - 1); + + userTicketShardingRepositoryV1.saveIgnoreDuplicate( + new UserTicketV1(user, ticketRepositoryV1.getReferenceById(ticketId)) + ); + } + + // 예약 취소 메서드 + @Transactional + public void cancelReservation(Long userId, Long ticketId) { + log.info("Cancelling reservation: userId = {}, ticketId = {}", userId, ticketId); + + // 예약 존재 여부 확인 + if (!userTicketShardingRepositoryV1.exists(userId, ticketId)) { + throw new IllegalStateException("예약 내역이 존재하지 않습니다."); + } + + // 예약 삭제 + userTicketShardingRepositoryV1.delete(userId, ticketId); + + // 티켓 수량 복원 (비관적 락으로 안전하게 처리) + TicketV1 ticket = ticketRepositoryV1.findByIdForUpdate(ticketId) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found")); + + ticket.setQuantity(ticket.getQuantity() + 1); + + // 매진 캐시 초기화 (optional) + if (ticket.getQuantity() > 0) { + ticketSoldOutCacheV1.unmarkSoldOut(ticketId); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java b/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java new file mode 100644 index 0000000..fad588d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/reserve/status/ReserveStatusV1.java @@ -0,0 +1,6 @@ +package com.quickpick.ureca.v1.reserve.status; + +public enum ReserveStatusV1 { + SUCCESS, + FAIL +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java new file mode 100644 index 0000000..7ca0a3c --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/cache/TicketSoldOutCacheV1.java @@ -0,0 +1,22 @@ +package com.quickpick.ureca.v1.ticket.cache; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class TicketSoldOutCacheV1 { + private final ConcurrentHashMap soldOutMap = new ConcurrentHashMap<>(); + + public boolean isSoldOut(Long ticketId) { + return soldOutMap.getOrDefault(ticketId, false); + } + + public void markSoldOut(Long ticketId) { + soldOutMap.put(ticketId, true); + } + + public void unmarkSoldOut(Long ticketId) { + soldOutMap.remove(ticketId); + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java new file mode 100644 index 0000000..6174623 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/controller/TicketControllerV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.controller; + +public class TicketControllerV1 { + + + +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/domain/TicketV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/domain/TicketV1.java new file mode 100644 index 0000000..9bb67cd --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/domain/TicketV1.java @@ -0,0 +1,59 @@ +package com.quickpick.ureca.v1.ticket.domain; + +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "ticket") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TicketV1 extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ticket_id") + private Long ticketId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private LocalDateTime startDate; + + @Column(nullable = false) + private LocalDateTime reserveDate; + + //@Version + //private Long version; + + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTicketV1s = new ArrayList<>(); + + // 재고 감소 메서드 + public void decreaseCount() { + if (this.quantity > 0) { + this.quantity--; + } else { + throw new RuntimeException("티켓이 매진되었습니다."); + } + } + + // Test용 + public TicketV1(String name, int i) { + this.name = name; + this.quantity = i; + } + +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java new file mode 100644 index 0000000..0c0bce7 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/projection/TicketQuantityProjectionV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.projection; + +public interface TicketQuantityProjectionV1 { + Long getTicketId(); + int getQuantity(); + +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java new file mode 100644 index 0000000..f78234d --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/repository/TicketRepositoryV1.java @@ -0,0 +1,54 @@ +package com.quickpick.ureca.v1.ticket.repository; + +import com.quickpick.ureca.v1.ticket.domain.TicketV1; +import com.quickpick.ureca.v1.ticket.projection.TicketQuantityProjectionV1; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Repository +public interface TicketRepositoryV1 extends JpaRepository { + + // 비관적 락 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select t from TicketV1 t where t.ticketId = :ticketId""") + Optional findByIdForUpdate(Long ticketId); + + // 비관적 락 (네이티브 쿼리) + @Query(value = "SELECT * FROM ticket WHERE ticket_id = :ticketId FOR UPDATE", nativeQuery = true) + TicketV1 findByIdForUpdateNative(@Param("ticketId") Long ticketId); + + + // open-in-view + FetchJoin + DTO + 비관적 락 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select t from TicketV1 t + left join fetch t.userTicketV1s ut + left join fetch ut.user + where t.ticketId = :ticketId + """) + Optional findByIdForUpdateWithUsers(Long ticketId); + + + // Projection 기반 조회 + @Query(value = "SELECT ticket_id AS ticketId, quantity AS quantity FROM ticket WHERE ticket_id = :ticketId FOR UPDATE", nativeQuery = true) + TicketQuantityProjectionV1 findQuantityForUpdate(@Param("ticketId") Long ticketId); + + @Modifying + @Query(value = "UPDATE ticket SET quantity = quantity - 1 WHERE ticket_id = :ticketId", nativeQuery = true) + void decreaseQuantity(@Param("ticketId") Long ticketId); + + + @Modifying + @Transactional + @Query(value = "UPDATE ticket SET quantity = quantity - 1 WHERE ticket_id = :ticketId AND quantity > 0", nativeQuery = true) + int decreaseQuantityIfAvailable(@Param("ticketId") Long ticketId); +} diff --git a/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java b/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java new file mode 100644 index 0000000..c6555ba --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/ticket/service/TicketServiceV1.java @@ -0,0 +1,7 @@ +package com.quickpick.ureca.v1.ticket.service; + +import org.springframework.stereotype.Service; + +@Service +public class TicketServiceV1 { +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java b/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java new file mode 100644 index 0000000..18c1049 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/controller/UserControllerV1.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.v1.user.controller; + +public class UserControllerV1 { +} diff --git a/src/main/java/com/quickpick/ureca/user/domain/User.java b/src/main/java/com/quickpick/ureca/v1/user/domain/User.java similarity index 65% rename from src/main/java/com/quickpick/ureca/user/domain/User.java rename to src/main/java/com/quickpick/ureca/v1/user/domain/User.java index 86eaaca..71003dc 100644 --- a/src/main/java/com/quickpick/ureca/user/domain/User.java +++ b/src/main/java/com/quickpick/ureca/v1/user/domain/User.java @@ -1,10 +1,9 @@ -package com.quickpick.ureca.user.domain; +package com.quickpick.ureca.v1.user.domain; -import com.quickpick.ureca.common.domain.BaseEntity; -import com.quickpick.ureca.userticket.domain.UserTicket; +import com.quickpick.ureca.v1.common.domain.BaseEntity; +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.ArrayList; import java.util.List; @@ -12,7 +11,10 @@ @Table @Entity @Getter +@Setter +@Builder @NoArgsConstructor +@AllArgsConstructor public class User extends BaseEntity { @Id @@ -36,6 +38,10 @@ public class User extends BaseEntity { private String gender; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List userTickets = new ArrayList<>(); + private List userTicketV1s = new ArrayList<>(); + + public User(String id) { + this.id = id; + } } diff --git a/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java new file mode 100644 index 0000000..0862f31 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/repository/UserRepositoryV1.java @@ -0,0 +1,9 @@ +package com.quickpick.ureca.v1.user.repository; + +import com.quickpick.ureca.v1.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepositoryV1 extends JpaRepository { +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java b/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java new file mode 100644 index 0000000..f46bf36 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/service/UserBulkInsertServiceV1.java @@ -0,0 +1,34 @@ +package com.quickpick.ureca.v1.user.service; + +import com.quickpick.ureca.v1.user.domain.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class UserBulkInsertServiceV1 { + + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void insertUsersInBulk(int userCount) { + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(new User("user" + i)); + } + + int batchSize = 100; + for (int i = 0; i < users.size(); i++) { + entityManager.persist(users.get(i)); + if (i % batchSize == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + } +} diff --git a/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java b/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java new file mode 100644 index 0000000..297c6fd --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/user/service/UserServiceV1.java @@ -0,0 +1,4 @@ +package com.quickpick.ureca.v1.user.service; + +public class UserServiceV1 { +} diff --git a/src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java b/src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java new file mode 100644 index 0000000..3825d41 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/userticket/domain/UserTicketV1.java @@ -0,0 +1,36 @@ +package com.quickpick.ureca.v1.userticket.domain; + +import com.quickpick.ureca.v1.ticket.domain.TicketV1; +import com.quickpick.ureca.v1.user.domain.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserTicketV1 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_ticket_id") + private Long userTicketId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id") + private TicketV1 ticket; + + public UserTicketV1(User user, TicketV1 ticket) { + this.user = user; + this.ticket = ticket; + } +} \ No newline at end of file diff --git a/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java new file mode 100644 index 0000000..b7785b4 --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketRepositoryV1.java @@ -0,0 +1,17 @@ +package com.quickpick.ureca.v1.userticket.repository; + +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserTicketRepositoryV1 extends JpaRepository { + + boolean existsByUser_UserIdAndTicket_TicketId(Long userId, Long ticketId); + + @Query(value = "SELECT 1 FROM user_ticket WHERE user_id = :userId AND ticket_id = :ticketId LIMIT 1", nativeQuery = true) + Integer existsUserTicketRaw(@Param("userId") Long userId, @Param("ticketId") Long ticketId); + + + +} diff --git a/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java new file mode 100644 index 0000000..20a2cac --- /dev/null +++ b/src/main/java/com/quickpick/ureca/v1/userticket/repository/UserTicketShardingRepositoryV1.java @@ -0,0 +1,67 @@ +package com.quickpick.ureca.v1.userticket.repository; + +import com.quickpick.ureca.v1.userticket.domain.UserTicketV1; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class UserTicketShardingRepositoryV1 { + + private final EntityManager em; + + // Shard 선택 + private String getTableName(Long userId) { + int shard = (int)(userId % 10); + return "user_ticket_" + shard; + } + + public boolean exists(Long userId, Long ticketId) { + String tableName = getTableName(userId); + String sql = "SELECT 1 FROM " + tableName + + " WHERE user_id = :userId AND ticket_id = :ticketId LIMIT 1"; + + List result = em.createNativeQuery(sql) + .setParameter("userId", userId) + .setParameter("ticketId", ticketId) + .getResultList(); + + return !result.isEmpty(); + } + + public void saveIgnoreDuplicate(UserTicketV1 userTicketV1) { + String tableName = getTableName(userTicketV1.getUser().getUserId()); + + String sql = "INSERT IGNORE INTO " + tableName + " (user_id, ticket_id) " + + "VALUES (:userId, :ticketId)"; + + em.createNativeQuery(sql) + .setParameter("userId", userTicketV1.getUser().getUserId()) + .setParameter("ticketId", userTicketV1.getTicket().getTicketId()) + .executeUpdate(); + } + + public void save(UserTicketV1 userTicketV1) { + String tableName = getTableName(userTicketV1.getUser().getUserId()); + String sql = "INSERT INTO " + tableName + " (user_id, ticket_id) VALUES (:userId, :ticketId)"; + + em.createNativeQuery(sql) + .setParameter("userId", userTicketV1.getUser().getUserId()) + .setParameter("ticketId", userTicketV1.getTicket().getTicketId()) + .executeUpdate(); + } + + public void delete(Long userId, Long ticketId) { + String tableName = getTableName(userId); + String sql = "DELETE FROM " + tableName + " WHERE user_id = :userId AND ticket_id = :ticketId"; + + em.createNativeQuery(sql) + .setParameter("userId", userId) + .setParameter("ticketId", ticketId) + .executeUpdate(); + } + +} diff --git a/src/test/java/com/quickpick/ureca/config/jwt/JwtFactory.java b/src/test/java/com/quickpick/ureca/config/jwt/JwtFactory.java new file mode 100644 index 0000000..ad0087d --- /dev/null +++ b/src/test/java/com/quickpick/ureca/config/jwt/JwtFactory.java @@ -0,0 +1,61 @@ +package com.quickpick.ureca.config.jwt; + +import com.quickpick.ureca.OAuth.auth.config.JwtPropertiesOAuth; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.Builder; +import lombok.Getter; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Getter +public class JwtFactory { //test용 jwt 토큰 생성 + private String subject = "test@email.com"; + private Date issuedAt = new Date(); + private Date expiration + = new Date( new Date().getTime() + Duration.ofDays(14).toMillis() ); + private Map claims = Collections.emptyMap(); + + @Builder + public JwtFactory(String subject, Date issuedAt, Date expiration + , Map claims) { + this.subject = subject != null ? subject : this.subject; + this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt; + this.expiration = expiration != null ? expiration : this.expiration; + this.claims = claims != null ? claims : this.claims; + } + + public static JwtFactory withDefaultValues() { + + return JwtFactory.builder().build(); + } // withDefaultValues + + public String createToken(JwtPropertiesOAuth jwtProperties) { + // 기본 클레임 설정 + Map tokenClaims = new HashMap<>(); + + // 표준 클레임 추가 + tokenClaims.put("sub", subject); // subject + tokenClaims.put("iss", jwtProperties.getIssuer()); // issuer + tokenClaims.put("iat", issuedAt); // issuedAt + tokenClaims.put("exp", expiration); // expiration + + // 사용자 정의 클레임 추가 (덮어쓰기 가능) + if (claims != null && !claims.isEmpty()) { + tokenClaims.putAll(claims); + } + + return Jwts.builder() + .claims(tokenClaims) + .signWith( + Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)), + Jwts.SIG.HS256 // 서명 알고리즘 명시 필수 + ) + .compact(); + } +} diff --git a/src/test/java/com/quickpick/ureca/config/jwt/TokenProviderTest.java b/src/test/java/com/quickpick/ureca/config/jwt/TokenProviderTest.java new file mode 100644 index 0000000..ff61375 --- /dev/null +++ b/src/test/java/com/quickpick/ureca/config/jwt/TokenProviderTest.java @@ -0,0 +1,120 @@ +package com.quickpick.ureca.config.jwt; + +import com.quickpick.ureca.OAuth.auth.config.JwtPropertiesOAuth; +import com.quickpick.ureca.OAuth.auth.config.TokenProviderOAuth; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import com.quickpick.ureca.OAuth.user.repository.UserRepositoryOAuth; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class TokenProviderTest { + @Autowired + private TokenProviderOAuth tokenProvider; + @Autowired + private UserRepositoryOAuth userRepository; + @Autowired + private JwtPropertiesOAuth jwtProperties; + + @DisplayName("토큰 생성 테스트") + @Test + void generateToken() { + UserOAuth testUser = userRepository.save(UserOAuth.builder() + .id("user@gmail.com") + .password("password") + .name("testUser") + .age(12) + .gender("male") + .build()); + + String token = tokenProvider.generateToken(testUser, Duration.ofDays(14)); + + Long userId = Jwts.parser() + .verifyWith( Keys.hmacShaKeyFor( + jwtProperties.getSecretKey().getBytes( StandardCharsets.UTF_8 ) ) ) + .build() + .parseSignedClaims(token) + .getPayload().get("user_id", Long.class); + + assertThat(userId).isEqualTo(testUser.getUserId()); + } + + @DisplayName("토큰 검증 테스트-일부러 틀리도록?") + @Test + void validateToken_fail() { + String token = JwtFactory.builder() + .expiration(new Date( new Date().getTime() - Duration.ofDays(7).toMillis() )) + .build() + .createToken(jwtProperties); + + boolean result; + try { + tokenProvider.validToken(token); + + result = true; + } catch (JwtException e) { + result = false; + } + assertThat(result).isFalse(); + } + + @DisplayName("토큰 검증 테스트-성공") + @Test + void validateToken_success() { + String token = JwtFactory.withDefaultValues() + .createToken(jwtProperties); + + boolean result; + try { + tokenProvider.validToken(token); + + result = true; + } catch (JwtException e) { + result = false; + } + assertThat(result).isTrue(); + } + + @DisplayName("토큰으로 인증 정보 가져오기") + @Test + public void getAuthentication() { + String userEmail = "user@gmail.com"; + String token = JwtFactory.builder() + .subject(userEmail) + .build() + .createToken(jwtProperties); + + Authentication authentication = tokenProvider.getAuthentication(token); + + assertThat( ( (UserDetails) authentication.getPrincipal() ).getUsername() ) + .isEqualTo(userEmail); + } // getAuthentication + + @DisplayName("토큰으로 유저 ID를 가져오기 테스트") + @Test + public void getUserId() { + Long userId = 1L; + String token = JwtFactory.builder() + .claims(Map.of("user_id", userId)) + .build() + .createToken(jwtProperties); + + Long userIdByToken = tokenProvider.getUserId(token); + + assertThat(userIdByToken).isEqualTo(userId); + } // getUserId +} diff --git a/src/test/java/com/quickpick/ureca/controller/TokenControllerTest.java b/src/test/java/com/quickpick/ureca/controller/TokenControllerTest.java new file mode 100644 index 0000000..9c32c09 --- /dev/null +++ b/src/test/java/com/quickpick/ureca/controller/TokenControllerTest.java @@ -0,0 +1,82 @@ +package com.quickpick.ureca.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.quickpick.ureca.OAuth.auth.config.JwtPropertiesOAuth; +import com.quickpick.ureca.OAuth.auth.domain.RefreshTokenOAuth; +import com.quickpick.ureca.OAuth.auth.dto.CreateAccessTokenRequestOAuth; +import com.quickpick.ureca.OAuth.auth.repository.RefreshTokenRepositoryOAuth; +import com.quickpick.ureca.config.jwt.JwtFactory; +import com.quickpick.ureca.OAuth.user.domain.UserOAuth; +import com.quickpick.ureca.OAuth.user.repository.UserRepositoryOAuth; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class TokenControllerTest { + + @Autowired + protected MockMvc mockMvc; + @Autowired + protected ObjectMapper objectMapper; + @Autowired + private WebApplicationContext context; + @Autowired + private JwtPropertiesOAuth jwtProperties; + @Autowired + private UserRepositoryOAuth userRepository; + @Autowired + private RefreshTokenRepositoryOAuth refreshTokenRepository; + + @BeforeEach + public void mockMvcSetUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + userRepository.deleteAll(); + } // mockMvcSetUp + + @DisplayName("createNewAccessToken : 새로운 액세스 토큰을 발급한다.") + @Test + public void createNewAccessToken() throws Exception { + final String url = "/auth/token"; + UserOAuth testUser = userRepository.save( UserOAuth.builder() + .id("user@gmail.com") + .password("test") + .name("test") + .age(123) + .gender("male") + .build() ); + String refreshToken = JwtFactory.builder() + .claims( Map.of( "user_id", testUser.getUserId() ) ) + .build() + .createToken(jwtProperties); + refreshTokenRepository.save( new RefreshTokenOAuth(testUser.getUserId(), refreshToken) ); + + CreateAccessTokenRequestOAuth request = new CreateAccessTokenRequestOAuth(); + request.setRefreshToken(refreshToken); + final String requestBody = objectMapper.writeValueAsString(request); + + ResultActions resultActions = mockMvc.perform( post(url) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody) ); + + resultActions + .andExpect(status().isCreated()) + .andExpect( jsonPath("$.accessToken").isNotEmpty() ); + } // createNewAccessToken + +} // class diff --git a/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java b/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java new file mode 100644 index 0000000..5282177 --- /dev/null +++ b/src/test/java/com/quickpick/ureca/v1/TicketReservationServiceTest.java @@ -0,0 +1,51 @@ +package com.quickpick.ureca.v1; + +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class TicketReservationServiceTest { + +// @Autowired private TicketRepositoryV1 ticketRepositoryV1; +// @Autowired private UserRepository userRepository; +// @Autowired private UserTicketRepository userTicketRepository; +// @Autowired private ReserveServiceV1 reserveServiceV1; +// +// @Autowired +// private UserBulkInsertServiceV1 userBulkInsertServiceV1; +// +// @Test +// @DisplayName("동시에 1000개의 요청으로 100개의 티켓을 예약한다.") +// void PessimisticReservationTest() throws InterruptedException { +// int userCount = 30000; +// int ticketQuantity = 100; +// +// Ticket ticket = new Ticket("SKT 콘서트", ticketQuantity); +// ticketRepositoryV1.save(ticket); +// +// userBulkInsertServiceV1.insertUsersInBulk(userCount); +// +// ExecutorService executorService = Executors.newFixedThreadPool(32); +// CountDownLatch latch = new CountDownLatch(userCount); +// +// List allUsers = userRepository.findAll(); +// +// for (User user : allUsers) { +// executorService.submit(() -> { +// try { +// reserveServiceV1.reserveTicket(user.getUserId(), ticket.getTicketId()); +// } catch (Exception ignored) { +// } finally { +// latch.countDown(); +// } +// }); +// } +// +// latch.await(); +// +// List reservations = userTicketRepository.findAll(); +// System.out.println("총 예약 수: " + reservations.size()); +// assertEquals(ticketQuantity, reservations.size()); +// } +}