Skip to content
15 changes: 11 additions & 4 deletions amumal/.env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
DB_URL=
DB_HOST=
DB_NAME=
DB_USERNAME=
DB_PASSWORD=

SERVER_URL=

JWT_SECRET=

MAX_FILE_SIZE=
MAX_REQUEST_SIZE=
POSTS_FILE_ACCESS_PATH=
PROFILE_FILE_ACCESS_PATH=
SERVER_URL=

AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_REGION=
AWS_S3_BUCKET=

DDL_AUTO_SET=
6 changes: 6 additions & 0 deletions amumal/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ dependencies {
// BCrypt
implementation 'org.springframework.security:spring-security-crypto'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
Expand All @@ -49,6 +52,9 @@ dependencies {

// Swagger (springdoc-openapi)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'

// AWS S3
implementation 'software.amazon.awssdk:s3:2.26.0'
}

tasks.named('test') {
Expand Down
3 changes: 3 additions & 0 deletions amumal/src/main/java/com/kbt/amumal/AmumalApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import java.util.TimeZone;

@SpringBootApplication
@EnableJpaAuditing
public class AmumalApplication {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
SpringApplication.run(AmumalApplication.class, args);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,72 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Tag(name = "인증", description = "로그인·로그아웃 API")
@Tag(name = "인증", description = "로그인·로그아웃·토큰 재발급 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {

private final AuthService authService;

@Operation(summary = "로그인", description = "이메일·비밀번호로 로그인하고 JWT 액세스 토큰을 반환합니다.")
@Operation(summary = "로그인", description = "이메일·비밀번호로 로그인합니다. Access Token은 body, Refresh Token은 HttpOnly 쿠키로 반환됩니다.")
@SecurityRequirements
@PostMapping("/")
public ApiResponse<?> login(@Valid @RequestBody AuthReqDTO.LoginReq request) {
String userInfo = authService.userLogin(request);
public ResponseEntity<ApiResponse<?>> login(@Valid @RequestBody AuthReqDTO.LoginReq request) {
AuthService.LoginResult result = authService.userLogin(request);

ResponseCookie refreshCookie = buildRefreshCookie(result.refreshToken(), 30L * 24 * 60 * 60);

return ApiResponse.success("로그인 성공", Map.of("accessToken", userInfo));
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(ApiResponse.success("로그인 성공", Map.of("accessToken", result.accessToken())));
}

@Operation(summary = "Access Token 재발급", description = "HttpOnly 쿠키의 Refresh Token으로 새 Access Token을 발급합니다.")
@SecurityRequirements
@PostMapping("/refresh")
public ApiResponse<?> refresh(
@CookieValue(name = "refreshToken", required = false) String refreshToken) {
String newAccessToken = authService.refresh(refreshToken);
return ApiResponse.success("토큰 재발급 성공", Map.of("accessToken", newAccessToken));
}

@Operation(summary = "로그아웃", description = "토큰 유효성을 검증합니다. 실제 토큰 삭제는 클라이언트에서 처리합니다.")
@Operation(summary = "로그아웃", description = "Refresh Token을 Redis에서 삭제하고 쿠키를 만료시킵니다.")
@PostMapping("/delete")
public ApiResponse<?> logout(@LoginUserId int userId) {
return ApiResponse.success("로그아웃 성공", null);
public ResponseEntity<ApiResponse<?>> logout(@LoginUserId int userId) {
authService.logout(userId);

// maxAge=0 으로 쿠키 즉시 만료
ResponseCookie expiredCookie = buildRefreshCookie("", 0);

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.body(ApiResponse.success("로그아웃 성공", null));
}

/**
* Refresh Token 쿠키 빌더
* - HttpOnly: JS 접근 차단
* - Secure: HTTPS 전용
* - SameSite=None: 크로스 도메인 허용
* - Domain=.amon.p-e.kr: 서브도메인 간 쿠키 공유
* - Path=/auth: 토큰 갱신·로그아웃 경로에만 전송
*/
private ResponseCookie buildRefreshCookie(String value, long maxAgeSeconds) {
return ResponseCookie.from("refreshToken", value)
.httpOnly(true)
.sameSite("None")
.secure(true)
.domain(".amon.p-e.kr")
.path("/")
.maxAge(maxAgeSeconds)
.build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,70 @@
import com.kbt.amumal.global.error.ErrorCode;
import com.kbt.amumal.global.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class AuthService {

private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
private final StringRedisTemplate redisTemplate;

@Value("${jwt.refresh_expiration_time}")
private long refreshExpTimeMs;

public String userLogin(AuthReqDTO.LoginReq request) {
private static final String REFRESH_PREFIX = "RT:";

public LoginResult userLogin(AuthReqDTO.LoginReq request) {
User loginUser = userRepository.findByEmail(request.email())
.orElseThrow(() -> new CustomException(ErrorCode.LOGIN_FAILED));

if (!passwordEncoder.matches(request.password(), loginUser.getPassword())) {
throw new CustomException(ErrorCode.LOGIN_FAILED);
}

// 로그인 성공 시 JWT 발급
return jwtUtil.createAccessToken(loginUser.getId(), loginUser.getEmail());
String accessToken = jwtUtil.createAccessToken(loginUser.getId(), loginUser.getEmail());
String refreshToken = jwtUtil.createRefreshToken(loginUser.getId(), loginUser.getEmail());

// 유저당 Refresh Token 1개만 유지 (기존 토큰 덮어쓰기 → 메모리 절약)
redisTemplate.opsForValue().set(
REFRESH_PREFIX + loginUser.getId(),
refreshToken,
Duration.ofMillis(refreshExpTimeMs)
);

return new LoginResult(accessToken, refreshToken);
}

public String refresh(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
throw new CustomException(ErrorCode.TOKEN_MISSING);
}

// 만료·위변조 시 GlobalExceptionHandler가 TOKEN_EXPIRED / TOKEN_INVALID 반환
jwtUtil.validateToken(refreshToken);

int userId = jwtUtil.getId(refreshToken);
String email = jwtUtil.getEmail(refreshToken);

String savedToken = redisTemplate.opsForValue().get(REFRESH_PREFIX + userId);
if (savedToken == null || !savedToken.equals(refreshToken)) {
throw new CustomException(ErrorCode.TOKEN_INVALID);
}

return jwtUtil.createAccessToken(userId, email);
}

public void logout(int userId) {
redisTemplate.delete(REFRESH_PREFIX + userId);
}

public record LoginResult(String accessToken, String refreshToken) {}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.kbt.amumal.domain.comment.repository;

import com.kbt.amumal.domain.comment.entity.Comment;
import com.kbt.amumal.domain.post.dto.CountProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface commentRepository extends JpaRepository<Comment, Integer> {
long countByPostIdAndDeletedAtIsNull(int postId);
List<Comment> findByPostIdAndDeletedAtIsNullOrderByCreatedAtAsc(int postId);
void deleteByPostId(int postId);
void deleteByUserId(int userId);

// N+1 방지: 여러 게시글의 댓글 수를 DTO Projection으로 한 번에 조회
@Query("SELECT new com.kbt.amumal.domain.post.dto.CountProjection(c.postId, COUNT(c)) FROM Comment c WHERE c.postId IN :postIds AND c.deletedAt IS NULL GROUP BY c.postId")
List<CountProjection> countsByPostIds(@Param("postIds") List<Integer> postIds);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.kbt.amumal.domain.post.dto;

public record CountProjection(Integer postId, Long count) {}
11 changes: 6 additions & 5 deletions amumal/src/main/java/com/kbt/amumal/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public class Post extends BaseEntity {
@Column(length = 500)
private String postImageUrl;

@Column(length = 255)
private String postImageOriginalName;

@Column(nullable = false)
private int userId;

Expand All @@ -40,15 +43,13 @@ public void updateContent(String content) {
this.content = content;
}

public void updatePostImage(String imageUrl) {
public void updatePostImage(String imageUrl, String originalName) {
this.postImageUrl = imageUrl;
}

public void incrementViewCount() {
this.viewCount++;
this.postImageOriginalName = originalName;
}

public void clearPostImage() {
this.postImageUrl = null;
this.postImageOriginalName = null;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.kbt.amumal.domain.post.repository;

import com.kbt.amumal.domain.post.dto.CountProjection;
import com.kbt.amumal.domain.post.entity.Like;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface LikeRepository extends JpaRepository<Like, Long> {
boolean existsByUserIdAndPostId(int userId, Integer postId); // 유저+게시글 존재하는지 (좋아요 눌렀는지)
void deleteByUserIdAndPostId(int userId, Integer postId); // 테이블에서 좋아요 삭제
long countByPostId(Integer postId); // 게시글 좋아요 수
void deleteByPostId(Integer postId);
void deleteByUserId(int userId);

// N+1 방지: 여러 게시글의 좋아요 수를 DTO Projection으로 한 번에 조회
@Query("SELECT new com.kbt.amumal.domain.post.dto.CountProjection(l.postId, COUNT(l)) FROM Like l WHERE l.postId IN :postIds GROUP BY l.postId")
List<CountProjection> countsByPostIds(@Param("postIds") List<Integer> postIds);
}
Loading