Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
b2b63cc
[#625] feat(Clap): incrementClapCount 반환값 수정
hyerinhwang-sailin Oct 17, 2025
16511b0
[#625] feat(ClapRepository): 복합키 조회(findByUserIdAndStampId) 추가
hyerinhwang-sailin Oct 17, 2025
b5307cf
[#625] feat(StampRepositoryCustom): incrementClapCountReturning 정의
hyerinhwang-sailin Oct 17, 2025
0994251
[#625] feat(StampRepositoryImpl): Postgres RETURNING 기반 원자 증가 구현 (cla…
hyerinhwang-sailin Oct 17, 2025
4dcd1ed
[#625] feat(StampRepository): extend StampRepositoryCustom
hyerinhwang-sailin Oct 17, 2025
7dea797
[#625] feat(ClapService): 유저별 박수 상한(50) 및 총합 반영 로직
hyerinhwang-sailin Oct 17, 2025
4705e20
[#625] feat(ClapRequest): AddClapRequest: 요청 증가량 검증 (@Positive)
hyerinhwang-sailin Oct 17, 2025
1e185c3
[#625] feat(ClapResponse): AddClapResponse 추가
hyerinhwang-sailin Oct 17, 2025
344bfd9
[#625] feat(ErrorCode): clap 관련 error code 추가
hyerinhwang-sailin Oct 17, 2025
b23c235
[#625] feat(SoptampFacade): 박수 관련 로직 위임
hyerinhwang-sailin Oct 17, 2025
bcfd21c
[#625] feat(StampResponseMapper): ClapResponse 변환 메서드 추가
hyerinhwang-sailin Oct 17, 2025
100f509
[#625] feat(StampService): getStampClapCount 추가
hyerinhwang-sailin Oct 17, 2025
45a707e
[#625] feat(StampController): /{stampId}/clap 엔드포인트 추가
hyerinhwang-sailin Oct 17, 2025
478e801
[#625] feat(ClapServiceTest): 정상 증가, 상한 컷팅, 50 도달, 자기글 금지, 잘못된 입력, 락 …
hyerinhwang-sailin Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/main/java/org/sopt/app/application/stamp/ClapService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.sopt.app.application.stamp;

import java.util.Objects;

import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.exception.ForbiddenException;
import org.sopt.app.common.exception.NotFoundException;
import org.sopt.app.common.response.ErrorCode;
import org.sopt.app.domain.entity.soptamp.Clap;
import org.sopt.app.domain.entity.soptamp.Stamp;
import org.sopt.app.interfaces.postgres.ClapRepository;
import org.sopt.app.interfaces.postgres.StampRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class ClapService {

private static final int MAX_RETRY = 3;

private final ClapRepository clapRepository;
private final StampRepository stampRepository;

/**
* 사용자(userId)가 스탬프(stampId)에 increment만큼 박수를 친다.
* - 자기 글이면 금지
* - Clap(유저별 1행)은 JPA + @Version (낙관적 락)으로 갱신, 재시도 최대 3회
* - Stamp 총합은 네이티브 원자 증가(RETURNING)
*/
@Transactional
public int addClap(Long userId, Long stampId, int increment) {
if (increment <= 0)
throw new BadRequestException(ErrorCode.INVALID_CLAP_COUNT);

// 1) 스탬프 조회 + 자기 글 금지
Stamp stamp = stampRepository.findById(stampId)
.orElseThrow(() -> new NotFoundException(ErrorCode.STAMP_NOT_FOUND));
if (Objects.equals(stamp.getUserId(), userId))
throw new ForbiddenException(ErrorCode.SELF_CLAP_FORBIDDEN);

// 2) Clap upsert (낙관적 락 + 재시도). 실제 적용된 양(applied)을 계산
int applied = upsertUserClapWithRetry(userId, stampId, increment);

// 3) 총합 반영 (네이티브 RETURNING) — applied가 0이면 스킵
if (applied > 0) {
stampRepository.incrementClapCountReturning(stampId, applied);
}
return applied;
}

/**
* Clap(유저별 1행) 업데이트.
* - 없으면 생성하고, 있으면 도메인 메서드로 상한(50) 컷팅
* - @Version 충돌 시 최대 MAX_RETRY 재시도
* - 반환: 이번 요청으로 실제로 적용된 증가량(applied)
*/
private int upsertUserClapWithRetry(Long userId, Long stampId, int requested) {
int attempt = 0;
while (true) {
attempt++;
try {
Clap clap = clapRepository.findByUserIdAndStampId(userId, stampId)
.orElseGet(() -> createClapSafely(userId, stampId));

int applied = clap.incrementClapCount(requested); // 도메인에서 0..50 컷팅
if (applied <= 0) return 0;

clapRepository.saveAndFlush(clap); // @Version 체크
return applied;

} catch (ObjectOptimisticLockingFailureException e) {
if (attempt >= MAX_RETRY) throw e;
}
}
}

/**
* (stamp_id, user_id) 유니크 제약 하에서 생성 경합을 안전 처리:
* - 없으면 생성
* - 동시 경합으로 유니크 예외시 재조회
*/
private Clap createClapSafely(Long userId, Long stampId) {
try {
Clap fresh = Clap.builder()
.userId(userId)
.stampId(stampId)
.clapCount(0)
.build();
return clapRepository.saveAndFlush(fresh);
} catch (DataIntegrityViolationException e) {
return clapRepository.findByUserIdAndStampId(userId, stampId)
.orElseThrow(() -> e);
}
}
Comment on lines +89 to +101
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 생성하는 경우에 clapCount까지 한번에 반영하지 않고 insert, update 를 진행하도록 하신 이유가 있을까요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

새 Clap 행을 만들 때 바로 clapCount를 더하면
생성 시점 경합이나 상한 계산이 SQL 단으로 분산돼 정합성 관리가 어려워져서
항상 0으로 초기화 후, 증가 로직을 단일 경로(incrementClapCount())로 통일하고자 했습니다!

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.sopt.app.common.event.EventPublisher;
import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.exception.ForbiddenException;
import org.sopt.app.common.exception.NotFoundException;
import org.sopt.app.common.response.ErrorCode;
import org.sopt.app.domain.entity.soptamp.Stamp;
import org.sopt.app.interfaces.postgres.StampRepository;
Expand Down Expand Up @@ -194,4 +195,11 @@ public Stamp getStampForDelete(Long stampId, Long userId) {
public void deleteAll() {
stampRepository.deleteAll();
}

@Transactional(readOnly = true)
public int getStampClapCount(Long stampId) {
return stampRepository.findById(stampId)
.orElseThrow(() -> new NotFoundException(ErrorCode.STAMP_NOT_FOUND))
.getClapCount();
}
}
4 changes: 4 additions & 0 deletions src/main/java/org/sopt/app/common/response/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ public enum ErrorCode {
INVALID_STAMP_ID("스탬프 ID가 존재하지 않습니다.", HttpStatus.BAD_REQUEST),
STAMP_DELETE_FORBIDDEN("자신의 스탬프만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN),

// CLAP
SELF_CLAP_FORBIDDEN("타인의 스탬프에만 박수 칠 수 있습니다.", HttpStatus.FORBIDDEN),
INVALID_CLAP_COUNT("잘못된 박수 횟수입니다.", HttpStatus.BAD_REQUEST),

// NOTIFICATION
NOTIFICATION_NOT_FOUND("존재하지 않는 알림입니다.", HttpStatus.NOT_FOUND),

Expand Down
14 changes: 10 additions & 4 deletions src/main/java/org/sopt/app/domain/entity/soptamp/Clap.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ public class Clap extends BaseEntity {
@Version
private Long version;

public void incrementClapCount(int increment) {
if (increment <= 0) return;
int nextCount = this.clapCount + increment;
this.clapCount = Math.min(nextCount, 50);
/**
* @param increment 요청된 증가값
* @return 실제로 적용된 증가값 (0 ~ increment)
*/
public int incrementClapCount(int increment) {
if (increment <= 0) return 0;
int before = this.clapCount;
int next = before + increment;
this.clapCount = Math.min(next, 50);
return this.clapCount - before;
}
}
13 changes: 12 additions & 1 deletion src/main/java/org/sopt/app/facade/SoptampFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import java.util.List;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.sopt.app.application.mission.MissionInfo.Level;
import org.sopt.app.application.mission.MissionService;
import org.sopt.app.application.soptamp.*;
import org.sopt.app.application.stamp.ClapService;
import org.sopt.app.application.stamp.StampInfo.Stamp;
import org.sopt.app.application.stamp.StampService;
import org.sopt.app.domain.entity.soptamp.Mission;
Expand All @@ -31,6 +31,7 @@ public class SoptampFacade {
private final SoptampUserService soptampUserService;
private final RankResponseMapper rankResponseMapper;
private final SoptampUserFinder soptampUserFinder;
private final ClapService clapService;

@Value("${makers.app.soptamp.report.url}")
private String formUrl;
Expand Down Expand Up @@ -81,6 +82,16 @@ public RankResponse.Detail findSoptampUserAndCompletedMissionByNickname(String n
return rankResponseMapper.of(soptampUserInfo, missionList);
}

@Transactional
public int addClap(Long userId, Long stampId, int increment) {
return clapService.addClap(userId, stampId, increment);
}

@Transactional(readOnly = true)
public int getStampClapCount(Long stampId) {
return stampService.getStampClapCount(stampId);
}

public SoptampReportResponse getReportUrl(){
return new SoptampReportResponse(formUrl);
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/org/sopt/app/interfaces/postgres/ClapRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.sopt.app.interfaces.postgres;

import java.util.Optional;

import org.sopt.app.domain.entity.soptamp.Clap;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClapRepository extends JpaRepository<Clap, Long> {

Optional<Clap> findByUserIdAndStampId(Long userId, Long stampId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import org.sopt.app.domain.entity.soptamp.Stamp;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StampRepository extends JpaRepository<Stamp, Long> {
public interface StampRepository extends JpaRepository<Stamp, Long>, StampRepositoryCustom {

List<Stamp> findAllByUserId(Long userId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.sopt.app.interfaces.postgres;

public interface StampRepositoryCustom {

/**
* stamp.clap_count를 increment만큼 원자적으로 증가시키고
* 증가된 clap_count와 version을 함께 반환한다.
*/
StampCounts incrementClapCountReturning(Long stampId, int increment);

record StampCounts(int clapCount, long version) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.sopt.app.interfaces.postgres;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;

import org.springframework.stereotype.Repository;

@Repository
public class StampRepositoryImpl implements StampRepositoryCustom {

@PersistenceContext
private EntityManager em;

@Override
public StampCounts incrementClapCountReturning(Long stampId, int increment) {
// Postgres 네이티브. 버전 증가까지 함께 처리해 JPA @Version과 의미 일치.
String sql = """
UPDATE stamp
SET clap_count = clap_count + :increment,
version = version + 1
WHERE id = :id
RETURNING clap_count, version
""";

Query q = em.createNativeQuery(sql);
q.setParameter("increment", increment);
q.setParameter("id", stampId);

Object[] row = (Object[])q.getSingleResult();
int newClapCount = ((Number)row[0]).intValue();
long newVersion = ((Number)row[1]).longValue();
return new StampCounts(newClapCount, newVersion);
}
}
21 changes: 21 additions & 0 deletions src/main/java/org/sopt/app/presentation/stamp/ClapRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.sopt.app.presentation.stamp;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Positive;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ClapRequest {

@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class AddClapRequest {
@Schema(description = "이번 요청에서 증가시킬 박수 수(양수)", example = "7", minimum = "1")
@Positive(message = "clapCount must be > 0")
private int clapCount;
}
}
25 changes: 25 additions & 0 deletions src/main/java/org/sopt/app/presentation/stamp/ClapResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.sopt.app.presentation.stamp;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ClapResponse {

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class AddClapResponse {
@Schema(description = "스탬프 아이디", example = "123")
private Long stampId;

@Schema(description = "이번 요청으로 실제 반영된 증가량(0..요청값)", example = "5")
private int appliedCount;

@Schema(description = "스탬프 총 박수 합계(상한 없음)", example = "203")
private int totalClapCount;
}
}
17 changes: 17 additions & 0 deletions src/main/java/org/sopt/app/presentation/stamp/StampController.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ public ResponseEntity<Void> deleteStampByUserId(@AuthenticationPrincipal Long us
return ResponseEntity.ok().build();
}

@Operation(summary = "스탬프에 박수치기")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "success", content = @Content),
@ApiResponse(responseCode = "500", description = "server error", content = @Content)
})
@PostMapping("/{stampId}/clap")
public ResponseEntity<ClapResponse.AddClapResponse> addClap(
@AuthenticationPrincipal Long userId,
@PathVariable Long stampId,
@Valid @RequestBody ClapRequest.AddClapRequest request
) {
int appliedCount = soptampFacade.addClap(userId, stampId, request.getClapCount());
int totalClapCount = soptampFacade.getStampClapCount(stampId);
Comment on lines +113 to +114
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

읽기, 쓰기 연산도 파사드로 분리 호출돼서 잘 설계해주신거같아요 👍🏻

ClapResponse.AddClapResponse response = stampResponseMapper.of(stampId, appliedCount, totalClapCount);
return ResponseEntity.ok(response);
}

@Operation(summary = "솝탬프 신고 URL 조회하기")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "success", content = @Content),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ public interface StampResponseMapper {

StampResponse.StampId of(Long stampId);

default ClapResponse.AddClapResponse of(Long stampId, int appliedCount, int totalClapCount) {
return new ClapResponse.AddClapResponse(stampId, appliedCount, totalClapCount);
}
}
Loading