findProfileByEmail(String email);
+}
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java
new file mode 100644
index 0000000..39d0f40
--- /dev/null
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java
@@ -0,0 +1,67 @@
+package com.ikae.snowthing.domain.member.repository;
+
+import static com.ikae.snowthing.domain.member.entity.QMember.member;
+import static com.ikae.snowthing.domain.member.entity.QMemberResort.memberResort;
+import static com.ikae.snowthing.domain.member.entity.QMemberRidingStyle.memberRidingStyle;
+import static com.ikae.snowthing.domain.member.entity.QResort.resort;
+import static com.ikae.snowthing.domain.member.entity.QRidingStyle.ridingStyle;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Repository;
+
+import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse;
+import com.ikae.snowthing.domain.member.entity.Member;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ /**
+ * 회원 프로필 조회 (1:N:M 다중 컬렉션 Cartesian Product 방지를 위한 3-Step O(1) 쿼리 분리 전략)
+ *
+ * [아키텍처 설계 배경 및 트레이드오프]: 1. 문제점: Member 엔티티는 member_resorts(1:N)와
+ * member_riding_styles(1:M)라는 2개의 독립된 일대다 관계를 가집니다. 이를 단일 SQL로 Fetch Join하면 N × M 카테시안
+ * 곱(Cartesian Product)이 발생하여 중복 데이터 전송 및 메모리 오버헤드가 발생합니다. 2. 3-Step 고정 쿼리 전략: - Step 1: Member
+ * 기본 정보 조회 (PK/Email 인덱스 단건 조회, 1 row) - Step 2: MemberId 기반 선호 Resort 이름 조회 (1~N rows) - Step
+ * 3: MemberId 기반 선호 RidingStyle 이름 조회 (1~M rows) 3. 트레이드오프: - DB Network RTT가 1회 -> 3회로 발생하지만,
+ * 회원 단건 조회이므로 데이터 크기에 비례하는 N+1이 아닌 고정 O(1) 쿼리로 동작합니다. - 카테시안 곱에 의한 메모리/네트워크 낭비를 물리적으로 방지하고 각
+ * 쿼리가 인덱스 조건으로 매우 빠르게 수행됩니다.
+ */
+ @Override
+ public Optional findProfileByEmail(String email) {
+ // Step 1: 회원 기본 엔티티 조회 (1 query)
+ Member foundMember =
+ queryFactory.selectFrom(member).where(member.email.eq(email)).fetchOne();
+
+ if (foundMember == null) {
+ return Optional.empty();
+ }
+
+ // Step 2: 선호 리조트명 조회 (1 query)
+ List resortNames =
+ queryFactory
+ .select(resort.name)
+ .from(memberResort)
+ .join(memberResort.resort, resort)
+ .where(memberResort.member.id.eq(foundMember.getId()))
+ .fetch();
+
+ // Step 3: 선호 라이딩 스타일명 조회 (1 query)
+ List ridingStyleNames =
+ queryFactory
+ .select(ridingStyle.styleName)
+ .from(memberRidingStyle)
+ .join(memberRidingStyle.ridingStyle, ridingStyle)
+ .where(memberRidingStyle.member.id.eq(foundMember.getId()))
+ .fetch();
+
+ return Optional.of(MemberLoginResponse.from(foundMember, resortNames, ridingStyleNames));
+ }
+}
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java
index 61fc40b..5e4d4f6 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java
@@ -1,12 +1,13 @@
package com.ikae.snowthing.domain.member.repository;
-import com.ikae.snowthing.domain.member.entity.MemberResort;
+import java.util.List;
+
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
-import java.util.List;
+import com.ikae.snowthing.domain.member.entity.MemberResort;
public interface MemberResortRepository extends JpaRepository {
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java
index e4a1f4b..df9050b 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java
@@ -1,16 +1,18 @@
package com.ikae.snowthing.domain.member.repository;
-import com.ikae.snowthing.domain.member.entity.MemberRidingStyle;
+import java.util.List;
+
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
-import java.util.List;
+import com.ikae.snowthing.domain.member.entity.MemberRidingStyle;
public interface MemberRidingStyleRepository extends JpaRepository {
- @Query("SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId")
+ @Query(
+ "SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId")
List findAllByMemberIdWithRidingStyle(@Param("memberId") Long memberId);
@Modifying(clearAutomatically = true, flushAutomatically = true)
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java
index 6a88d1e..a90c81b 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java
@@ -1,9 +1,10 @@
package com.ikae.snowthing.domain.member.repository;
-import com.ikae.snowthing.domain.member.entity.Resort;
+import java.util.Optional;
+
import org.springframework.data.jpa.repository.JpaRepository;
-import java.util.Optional;
+import com.ikae.snowthing.domain.member.entity.Resort;
public interface ResortRepository extends JpaRepository {
Optional findByName(String name);
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java
index 1a89769..1960c7c 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java
@@ -1,9 +1,10 @@
package com.ikae.snowthing.domain.member.repository;
-import com.ikae.snowthing.domain.member.entity.RidingStyle;
+import java.util.Optional;
+
import org.springframework.data.jpa.repository.JpaRepository;
-import java.util.Optional;
+import com.ikae.snowthing.domain.member.entity.RidingStyle;
public interface RidingStyleRepository extends JpaRepository {
Optional findByStyleName(String styleName);
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java
index cc97dd5..ce3b8ed 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java
@@ -1,13 +1,16 @@
package com.ikae.snowthing.domain.member.service;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
import com.ikae.snowthing.domain.member.dto.ResortResponse;
import com.ikae.snowthing.domain.member.dto.RidingStyleResponse;
import com.ikae.snowthing.domain.member.repository.ResortRepository;
import com.ikae.snowthing.domain.member.repository.RidingStyleRepository;
-import java.util.List;
+
import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -18,14 +21,10 @@ public class MasterDataService {
private final RidingStyleRepository ridingStyleRepository;
public List getAllResorts() {
- return resortRepository.findAll().stream()
- .map(ResortResponse::from)
- .toList();
+ return resortRepository.findAll().stream().map(ResortResponse::from).toList();
}
public List getAllRidingStyles() {
- return ridingStyleRepository.findAll().stream()
- .map(RidingStyleResponse::from)
- .toList();
+ return ridingStyleRepository.findAll().stream().map(RidingStyleResponse::from).toList();
}
}
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java
index 9e1522c..fa32975 100644
--- a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java
+++ b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java
@@ -1,17 +1,20 @@
package com.ikae.snowthing.domain.member.service;
+import java.util.List;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest;
import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest;
import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse;
import com.ikae.snowthing.domain.member.entity.*;
import com.ikae.snowthing.domain.member.repository.*;
-import lombok.RequiredArgsConstructor;
-import org.springframework.dao.DataIntegrityViolationException;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+import com.ikae.snowthing.global.error.ErrorCode;
+import com.ikae.snowthing.global.exception.CustomAuthException;
-import java.util.List;
+import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
@@ -28,80 +31,102 @@ public class MemberService {
@Transactional
public MemberSignUpResponse signUp(MemberSignUpRequest request) {
if (memberRepository.existsByEmail(request.getEmail())) {
- throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_EMAIL);
+ throw new CustomAuthException(ErrorCode.DUPLICATE_EMAIL);
}
if (memberRepository.existsByNickname(request.getNickname())) {
- throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME);
+ throw new CustomAuthException(ErrorCode.DUPLICATE_NICKNAME);
}
String encodedPassword = passwordEncoder.encode(request.getPassword());
Member member = request.toEntity(encodedPassword);
-
- try {
- Member savedMember = memberRepository.saveAndFlush(member);
-
- if (request.getResortIds() != null && !request.getResortIds().isEmpty()) {
- List resorts = resortRepository.findAllById(request.getResortIds());
- List memberResorts = resorts.stream()
- .map(resort -> MemberResort.builder().member(savedMember).resort(resort).build())
- .toList();
- memberResortRepository.saveAll(memberResorts);
- }
-
- if (request.getRidingStyleIds() != null && !request.getRidingStyleIds().isEmpty()) {
- List ridingStyles = ridingStyleRepository.findAllById(request.getRidingStyleIds());
- List memberRidingStyles = ridingStyles.stream()
- .map(style -> MemberRidingStyle.builder().member(savedMember).ridingStyle(style).build())
- .toList();
- memberRidingStyleRepository.saveAll(memberRidingStyles);
- }
-
- return MemberSignUpResponse.from(savedMember);
- } catch (DataIntegrityViolationException e) {
- throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_EMAIL);
+
+ Member savedMember = memberRepository.save(member);
+
+ if (!request.getResortIds().isEmpty()) {
+ List resorts = resortRepository.findAllById(request.getResortIds());
+ List memberResorts =
+ resorts.stream()
+ .map(
+ resort ->
+ MemberResort.builder()
+ .member(savedMember)
+ .resort(resort)
+ .build())
+ .toList();
+ memberResortRepository.saveAll(memberResorts);
+ }
+
+ if (!request.getRidingStyleIds().isEmpty()) {
+ List ridingStyles =
+ ridingStyleRepository.findAllById(request.getRidingStyleIds());
+ List memberRidingStyles =
+ ridingStyles.stream()
+ .map(
+ style ->
+ MemberRidingStyle.builder()
+ .member(savedMember)
+ .ridingStyle(style)
+ .build())
+ .toList();
+ memberRidingStyleRepository.saveAll(memberRidingStyles);
}
+
+ return MemberSignUpResponse.from(savedMember);
}
@Transactional
public MemberSignUpResponse updateMyProfile(String email, MemberProfileUpdateRequest request) {
- Member member = memberRepository.findByEmail(email)
- .orElseThrow(() -> new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.MEMBER_NOT_FOUND));
+ Member member =
+ memberRepository
+ .findByEmail(email)
+ .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND));
// 닉네임 변경 시 기존 닉네임과 다르고 타 유저와 중복되는지 사전 검사
- if (!member.getNickname().equals(request.getNickname()) && memberRepository.existsByNickname(request.getNickname())) {
- throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME);
+ if (!member.getNickname().equals(request.getNickname())
+ && memberRepository.existsByNickname(request.getNickname())) {
+ throw new CustomAuthException(ErrorCode.DUPLICATE_NICKNAME);
+ }
+
+ // 1. 기본 프로필 정보 업데이트 (Dirty Checking)
+ member.updateProfile(
+ request.getNickname(),
+ request.getBio(),
+ request.getDepartureRegion(),
+ member.getProfileImageUrl());
+
+ // 2. 기존 N:M 선호 스키장 삭제 후 재등록
+ memberResortRepository.deleteAllByMemberId(member.getId());
+ if (!request.getResortIds().isEmpty()) {
+ List resorts = resortRepository.findAllById(request.getResortIds());
+ List newMemberResorts =
+ resorts.stream()
+ .map(
+ resort ->
+ MemberResort.builder()
+ .member(member)
+ .resort(resort)
+ .build())
+ .toList();
+ memberResortRepository.saveAll(newMemberResorts);
}
- try {
- // 1. 기본 프로필 정보 업데이트 (Dirty Checking)
- member.updateProfile(request.getNickname(), request.getBio(), request.getDepartureRegion(), member.getProfileImageUrl());
-
- // 2. 기존 N:M 선호 스키장 삭제 후 재등록
- memberResortRepository.deleteAllByMemberId(member.getId());
- if (request.getResortIds() != null && !request.getResortIds().isEmpty()) {
- List resorts = resortRepository.findAllById(request.getResortIds());
- List newMemberResorts = resorts.stream()
- .map(resort -> MemberResort.builder().member(member).resort(resort).build())
- .toList();
- memberResortRepository.saveAll(newMemberResorts);
- }
-
- // 3. 기존 N:M 라이딩 성향 삭제 후 재등록
- memberRidingStyleRepository.deleteAllByMemberId(member.getId());
- if (request.getRidingStyleIds() != null && !request.getRidingStyleIds().isEmpty()) {
- List ridingStyles = ridingStyleRepository.findAllById(request.getRidingStyleIds());
- List newMemberRidingStyles = ridingStyles.stream()
- .map(style -> MemberRidingStyle.builder().member(member).ridingStyle(style).build())
- .toList();
- memberRidingStyleRepository.saveAll(newMemberRidingStyles);
- }
-
- // 동시성 닉네임 중복 충돌을 DB Unique Index 레벨에서 즉시 감지하기 위해 flush
- memberRepository.flush();
-
- return MemberSignUpResponse.from(member);
- } catch (DataIntegrityViolationException e) {
- throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME);
+ // 3. 기존 N:M 라이딩 성향 삭제 후 재등록
+ memberRidingStyleRepository.deleteAllByMemberId(member.getId());
+ if (!request.getRidingStyleIds().isEmpty()) {
+ List ridingStyles =
+ ridingStyleRepository.findAllById(request.getRidingStyleIds());
+ List newMemberRidingStyles =
+ ridingStyles.stream()
+ .map(
+ style ->
+ MemberRidingStyle.builder()
+ .member(member)
+ .ridingStyle(style)
+ .build())
+ .toList();
+ memberRidingStyleRepository.saveAll(newMemberRidingStyles);
}
+
+ return MemberSignUpResponse.from(member);
}
}
diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java
new file mode 100644
index 0000000..c2245c5
--- /dev/null
+++ b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java
@@ -0,0 +1,127 @@
+package com.ikae.snowthing.domain.post.controller;
+
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import com.ikae.snowthing.domain.post.dto.*;
+import com.ikae.snowthing.domain.post.service.PostService;
+import com.ikae.snowthing.global.security.CustomUserDetails;
+import com.ikae.snowthing.global.web.AnonymousVoterCookieManager;
+import com.ikae.snowthing.global.web.ClientIpResolver;
+import com.ikae.snowthing.global.web.ViewCountCookieManager;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequestMapping({"/api/posts", "/api/v1/posts"})
+@RequiredArgsConstructor
+public class PostController {
+
+ private static final String DEFAULT_PAGE_SIZE_PARAM = "20";
+
+ private final PostService postService;
+ private final ClientIpResolver clientIpResolver;
+ private final ViewCountCookieManager viewCountCookieManager;
+ private final AnonymousVoterCookieManager anonymousVoterCookieManager;
+
+ @PostMapping
+ public ResponseEntity createPost(
+ @Valid @RequestBody PostCreateRequest request,
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ HttpServletRequest httpRequest) {
+ String clientIp = clientIpResolver.resolve(httpRequest);
+ PostResponse response = postService.createPost(request, userDetails, clientIp);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @GetMapping("/{publicId}")
+ public ResponseEntity getPostDetail(
+ @PathVariable String publicId,
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ boolean shouldIncreaseViewCount =
+ viewCountCookieManager.markIfFirstView(publicId, request, response);
+ PostDetailResponse postDetailResponse =
+ postService.getPostDetail(publicId, userDetails, shouldIncreaseViewCount);
+ return ResponseEntity.ok(postDetailResponse);
+ }
+
+ @GetMapping
+ public ResponseEntity>
+ searchPostsByOffset(
+ @RequestParam(required = false) String categoryCode,
+ @RequestParam(required = false) Long resortId,
+ @RequestParam(required = false) SearchType searchType,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) SortType sortType,
+ @RequestParam(required = false, defaultValue = "1") Integer page,
+ @RequestParam(defaultValue = DEFAULT_PAGE_SIZE_PARAM) int size) {
+ PostSearchRequest request =
+ new PostSearchRequest(
+ categoryCode, resortId, searchType, keyword, sortType, page, null, size);
+ return ResponseEntity.ok(postService.searchPostsByOffset(request));
+ }
+
+ @GetMapping("/scroll")
+ public ResponseEntity>
+ searchPostsByCursor(
+ @RequestParam(required = false) String categoryCode,
+ @RequestParam(required = false) Long resortId,
+ @RequestParam(required = false) SearchType searchType,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) SortType sortType,
+ @RequestParam(required = false) String cursor,
+ @RequestParam(defaultValue = DEFAULT_PAGE_SIZE_PARAM) int size) {
+ PostSearchRequest request =
+ new PostSearchRequest(
+ categoryCode, resortId, searchType, keyword, sortType, null, cursor, size);
+ return ResponseEntity.ok(postService.searchPostsByCursor(request));
+ }
+
+ @PutMapping("/{publicId}")
+ public ResponseEntity updatePost(
+ @PathVariable String publicId,
+ @Valid @RequestBody PostUpdateRequest request,
+ @AuthenticationPrincipal CustomUserDetails userDetails) {
+ PostResponse response = postService.updatePost(publicId, request, userDetails);
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping("/{publicId}")
+ public ResponseEntity