Skip to content

Commit

Permalink
Merge pull request #82 from Soongsil-CoffeeChat/feat/#79
Browse files Browse the repository at this point in the history
동시성 이슈 : 분산 Lock으로 해결
  • Loading branch information
KimKyoHwee authored Jul 23, 2024
2 parents 946aff6 + b27c128 commit 5ac62f4
Show file tree
Hide file tree
Showing 7 changed files with 7,880 additions and 89 deletions.
7,532 changes: 7,532 additions & 0 deletions application.log

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.soongsil.CoffeeChat.controller.handler;

import com.soongsil.CoffeeChat.controller.ApplicationController;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException;

@ControllerAdvice(assignableTypes = ApplicationController.class)
public class ApplicationExceptionHandler {

@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<String> handleResponseStatusException(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode()).body(ex.getReason());
}


@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred");
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import com.soongsil.CoffeeChat.entity.Mentor;

import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
public class ApplicationCreateRequest {
@JsonProperty("date")
Expand All @@ -28,6 +30,13 @@ public class ApplicationCreateRequest {
@JsonProperty("mentor_id")
private Long mentorId;

public ApplicationCreateRequest(LocalDate date, LocalTime startTime, LocalTime endTime, Long mentorId) {
this.date=date;
this.startTime=startTime;
this.endTime=endTime;
this.mentorId=mentorId;
}

public Application toEntity(Mentor mentor, Mentee mentee) {
return Application.builder()
.date(this.date)
Expand Down
103 changes: 52 additions & 51 deletions src/main/java/com/soongsil/CoffeeChat/entity/PossibleDate.java
Original file line number Diff line number Diff line change
@@ -1,51 +1,52 @@
package com.soongsil.CoffeeChat.entity;

import java.time.LocalDate;
import java.time.LocalTime;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.soongsil.CoffeeChat.dto.PossibleDateRequestDto;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@ToString(of = {"id", "date", "startTime", "endTime", "isActive"})
public class PossibleDate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "possible_date_id")
private Long id;

@Setter
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mentor_id")
private Mentor mentor;

@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate date;

@JsonFormat(pattern = "HH:mm") //datetimeformat은 ss까지 전부 다 받아야 오류안남
LocalTime startTime;

@JsonFormat(pattern = "HH:mm")
LocalTime endTime;

@Column
@Setter
@Builder.Default
private boolean isActive=true;

public static PossibleDate from(PossibleDateRequestDto dto) {
return PossibleDate.builder()
.date(dto.getDate())
.startTime(dto.getStartTime())
.endTime(dto.getEndTime())
.isActive(true)
.build();
}
}
package com.soongsil.CoffeeChat.entity;

import java.time.LocalDate;
import java.time.LocalTime;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.soongsil.CoffeeChat.dto.PossibleDateRequestDto;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString(of = {"id", "date", "startTime", "endTime", "isActive"})
public class PossibleDate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "possible_date_id")
private Long id;

@Setter
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mentor_id")
private Mentor mentor;

@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate date;

@JsonFormat(pattern = "HH:mm") //datetimeformat은 ss까지 전부 다 받아야 오류안남
LocalTime startTime;

@JsonFormat(pattern = "HH:mm")
LocalTime endTime;

@Column
@Setter
@Builder.Default
private boolean isActive=true;

public static PossibleDate from(PossibleDateRequestDto dto) {
return PossibleDate.builder()
.date(dto.getDate())
.startTime(dto.getStartTime())
.endTime(dto.getEndTime())
.isActive(true)
.build();
}
}
145 changes: 107 additions & 38 deletions src/main/java/com/soongsil/CoffeeChat/service/ApplicationService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package com.soongsil.CoffeeChat.service;

import com.soongsil.CoffeeChat.entity.*;
import com.soongsil.CoffeeChat.enums.ApplicationStatus;
import com.soongsil.CoffeeChat.repository.PossibleDate.PossibleDateRepository;
import jakarta.mail.MessagingException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

Expand All @@ -20,11 +27,14 @@

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.web.server.ResponseStatusException;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Service
@RequiredArgsConstructor
Expand All @@ -40,53 +50,112 @@ public class ApplicationService {
@Autowired
private ApplicationContext applicationContext; // 프록시를 통해 자신을 호출하기 위해 ApplicationContext 주입

@Autowired
private RedisTemplate<String, String> redisTemplate;


@Transactional
public ApplicationCreateResponse createApplication(ApplicationCreateRequest request, String userName) throws Exception {
// 필요한 엔티티들 영속성 컨텍스트에 모두 올리기
System.out.println("mentorid: " + request.getMentorId() + ", " + request.getDate() + ", " + request.getStartTime() + ", " + request.getEndTime());
User findMentorUser = userRepository.findByMentorIdWithFetch(request.getMentorId());
Mentor findMentor = findMentorUser.getMentor();
User findMenteeUser = userRepository.findByUsername(userName);
Mentee findMentee = findMenteeUser.getMentee();

// 사용자가 신청한 시간대에 해당하는 PossibleDate의 Active 변경로직
LocalTime startTime = request.getStartTime();

// 특정 mentorId와 startTime을 가진 PossibleDate 엔티티를 가져오는 JPQL 쿼리
TypedQuery<PossibleDate> query = em.createQuery(
"SELECT p FROM PossibleDate p JOIN p.mentor m WHERE m.id = :mentorId AND p.startTime = :startTime",
PossibleDate.class);
query.setParameter("mentorId", request.getMentorId());
query.setParameter("startTime", startTime);

// 결과를 가져옴
Optional<PossibleDate> possibleDateOpt = query.getResultList().stream().findFirst();

// 찾은 PossibleDate의 Active상태 변경
if (possibleDateOpt.isPresent()) {
PossibleDate possibleDate = possibleDateOpt.get();
System.out.println("possibleDate.getId() = " + possibleDate.getId());
possibleDate.setActive(false);
possibleDateRepository.save(possibleDate);
} else {
throw new Exception("NOT FOUND"); // 500에러
}

// 성사된 커피챗 생성후 반환
Application savedApplication = applicationRepository.save(request.toEntity(findMentor, findMentee));
System.out.println("여긴들어옴");
String lockKey = "lock:" + request.getMentorId() + ":" +request.getDate()+":"+ request.getStartTime();
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();

// 비동기 메일 발송 메서드를 프록시를 통해 호출
ApplicationService proxy = applicationContext.getBean(ApplicationService.class);
proxy.sendApplicationMatchedEmailAsync(findMenteeUser.getEmail(), findMentorUser.getName(),
findMenteeUser.getName(), savedApplication.getDate(), savedApplication.getStartTime(),
savedApplication.getEndTime());
boolean isLockAcquired = valueOperations.setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS);
if (!isLockAcquired) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Lock을 획득하지 못하였습니다."); //409반환
}

return ApplicationCreateResponse.from(savedApplication);
try {
System.out.println("mentorid: " + request.getMentorId() + ", " + request.getDate() + ", " + request.getStartTime() + ", " + request.getEndTime());
User findMentorUser = userRepository.findByMentorIdWithFetch(request.getMentorId());
Mentor findMentor = findMentorUser.getMentor();
User findMenteeUser = userRepository.findByUsername(userName);
Mentee findMentee = findMenteeUser.getMentee();

LocalTime startTime = request.getStartTime();
LocalDate date = request.getDate();

// possibleDate 불러오는 JPQL
TypedQuery<PossibleDate> query = em.createQuery(
"SELECT p FROM PossibleDate p JOIN p.mentor m WHERE m.id = :mentorId AND p.startTime = :startTime AND p.date = :date",
PossibleDate.class);
query.setParameter("mentorId", request.getMentorId());
query.setParameter("startTime", startTime);
query.setParameter("date", date);

Optional<PossibleDate> possibleDateOpt = query.getResultList().stream().findFirst();

if (possibleDateOpt.isPresent()) {
PossibleDate possibleDate = possibleDateOpt.get();
if (!possibleDate.isActive()) {
throw new ResponseStatusException(HttpStatus.GONE, "이미 신청된 시간입니다."); //410 반환
}
System.out.println("possibleDate.getId() = " + possibleDate.getId());
possibleDate.setActive(false);
possibleDateRepository.save(possibleDate);
} else {
throw new Exception("NOT FOUND");
}

Application savedApplication = applicationRepository.save(request.toEntity(findMentor, findMentee));

ApplicationService proxy = applicationContext.getBean(ApplicationService.class);
proxy.sendApplicationMatchedEmailAsync(findMenteeUser.getEmail(), findMentorUser.getName(),
findMenteeUser.getName(), savedApplication.getDate(), savedApplication.getStartTime(),
savedApplication.getEndTime());

return ApplicationCreateResponse.from(savedApplication);
} finally {
redisTemplate.delete(lockKey);
}
}

@Async("mailExecutor")
public void sendApplicationMatchedEmailAsync(String email, String mentorName, String menteeName, LocalDate date, LocalTime startTime, LocalTime endTime) throws MessagingException {
emailUtil.sendApplicationMatchedEmail(email, mentorName, menteeName, date, startTime, endTime);
}

//동시성 테스트용
private static final Logger logger = LoggerFactory.getLogger(ApplicationService.class);
private static final AtomicInteger transactionCounter = new AtomicInteger(0); //트랜잭션마다 ID부여
@Transactional
public Application createApplicationIfPossible(Long possibleDateId, Mentor mentor, Mentee mentee) throws Exception {
int transactionId = transactionCounter.incrementAndGet(); //트랜잭션 ID 1씩 증가하며 부여
MDC.put("transactionId", String.valueOf(transactionId)); //로그에 트랜잭션ID 띄우기
MDC.put("threadId", String.valueOf(Thread.currentThread().getId())); //로그에 스레드ID 띄우기

try {
logger.info("aaa트랜잭션 시작");

PossibleDate possibleDate = em.find(PossibleDate.class, possibleDateId);

if (possibleDate != null && possibleDate.isActive()) { //Active상태면, Application생성
possibleDate.setActive(false); //중요! active상태를 false로 변경
em.merge(possibleDate);

Application application = Application.builder()
.mentor(mentor)
.mentee(mentee)
.date(possibleDate.getDate())
.startTime(possibleDate.getStartTime())
.endTime(possibleDate.getEndTime())
.accept(ApplicationStatus.UNMATCHED)
.build();
em.persist(application);

logger.info("aaaApplication 생성: {}", application);
return application;
} else {
logger.error("aaaAplication 생성 실패-Active하지 않음.");
throw new Exception("The PossibleDate is already booked or does not exist.");
}
} catch (Exception e) {
logger.error("aaaAplication 생성중 에러: ", e);
throw e;
} finally {
logger.info("aaa트랜잭션 종료");
MDC.clear();
}
}

}
27 changes: 27 additions & 0 deletions src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<configuration>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [transactionId=%X{transactionId}] [threadId=%X{threadId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>application.log</file>
<append>true</append>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [transactionId=%X{transactionId}] [threadId=%X{threadId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<root level="info">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>

<logger name="com.soongsil.CoffeeChat" level="debug" additivity="false">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>

</configuration>
Loading

0 comments on commit 5ac62f4

Please sign in to comment.