1. API 설계 계획

성실도 관련 기능은 크게 3가지가 있었다.

1. 홈 화면에서 성실도 점수 클릭하면 성실도 페이지에서 성실도 점수와 지각 히스토리, 약속 히스토리를 볼 수 있다.

2. 사용자의 의지 제고를 위해 한 달에 한 번 성실도 점수를 초기화할 수 있다.

3. 사용자의 의지 제고를 위해 성실도 점수를 초기화할 때 격려와 다짐 팝업의 체크박스에 체크하고 초기화할 수 있다.

 

 1번의 경우 처음에 성실도 페이지 반환 API를 만들어서 성실도 점수, 지각 히스토리, 약속 히스토리를 하나의 컨트롤러로 처리하려고 하였는데 이렇게하면 물론 장점도 있지만 확장성 측면에서 좋지 않아 API를 각각 구성하기로 하였다. 이에 대한 고민은 본 글 4.고민한 내용에 정리해두었다. 약속 히스토리 조회 API는 협업하는 BE개발자가 약속 관련 기능을 구현하면서 이미 구현하였기 때문에 성실도 점수 조회 API와 지각 히스토리 조회 API를 각각 구현하면 된다.

 2번의 경우 성실도 점수 초기화하는 API를 구현해야하는데 이 때 문제가 생기는 것이 기존에는 성실도 점수를 모든 약속 수와 모든 지각 약속 수를 바탕으로 계산했는데, 성실도 초기화 기능이 있다면 초기화 이후의 약속 수와 초기화 이후의 지각수로 성실도 점수를 계산해야한다. 따라서 초기화 전과 후를 구분할 수 있는 속성이 스키마에 추가돼야했다. 이에 대한 고민 및 해결은 2.구현에서 다룰 예정이고 어떤 교훈을 4.고민한 내용에 정리할 예정이다.

 3번의 경우 백엔드가 할 일은 없다. 2번에서 구현한 성실도 점수 초기화 API에 FE에서 요청을 보내고 올바른 응답을 받으면 FE단에서 팝업, 체크박스를 구성하면 된다.

 

그리고 추가로 준비과정이 종료될 때마다 지각시간과 성실도 점수를 업데이트해줘야한다. 

준비과정 종료 시 지각시간과 성실도점수를 업데이트하는 API도 구현해야한다

 

정리하자면, 

1. 성실도 점수 조회 API

2. 지각 히스토리 조회 API

3. 성실도 점수 초기화 API

4. 준비과정 종료 시 속성들 업데이트 API

총 4가지의 API를 구현하면 된다.  

 

2. 구현

2-1. 성실도 점수 조회 API

조회하는 API를 구현하는 것은 어렵지 않다.

컨트롤러에서 엑세스토큰으로 userId추출하고 서비스계층에 userId넘기면

서비스계층에서 userId를 바탕으로 퍼시스턴스계층에 저장된 User데이터를 가져온다.

User데이터에서 Getter로 성실도점수 가져와서 컨트롤러에 다시 넘겨주면 성실도 점수 조회가 완료된다.

 

로직과 구현이 간단해 코드는 생략한다.

2-2. 지각 히스토리 조회 API

지각 히스토리를 조회하려면 어떤 약속이 지각했는지에 대한 정보가 약속 테이블에 있어야함.

따라서 약속 테이블에 지각시간이라는 속성을 추가하였음.

 

시작되지 않은 약속이면 지각시간이 NULL로 설정될 것이고(약속 생성할 때 따로 지각시간 초기화를 하지 않으면 됨)

사용자가 만약 정해진 시간 내에 준비과정 종료버튼을 누르지 않는다면 얼마나 지각했는 지(분 단위)가 지각시간에 저장될 것임.

사용자가 만약 정해진 시간 내에 준비과정 종료버튼을 누른다면 지각시간에 0(분)이 저장될 것임.

 

준비과정 종료 시 지각시간 업데이트하는 API는 2-4에서 구현하도록하고

 

다시 지각시간 히스토리 조회하는 API 구현 방법은

2-1의 성실도 점수 조회 API와 로직이 유사해 생략하겠다.

 

자세한 코드는 아래 커밋참고(성실도 페이지 GET하는 API를 구현했던 커밋이라 컨트롤러는 무시, 서비스계층만 참고하자)

https://github.com/DevKor-github/OnTime-back/commit/991ba7d0130e9c464b21fd3a57fe7b12fcc11a10

2-3. 성실도 점수 초기화 API

이 부분이 사실 하이라이트라고 봐도 무방해 조금 자세히 설명하겠다. 스크럼에서도 이 부분을 발표했다.

Ontime에는 성실도 점수 초기화 기능이 있다.

성실도 점수가 너무 낮아 사용자가 앱 사용 의지를 잃는 것을 방지하기 위한 기능으로, 사용자는 성실도 점수를 한 달에 한 번 초기화 가능하다.

 

성실도 점수 초기화 기능을 도입 하기 전 성실도 점수는 다음과 같이 계산되었다.

[100 - (총 지각 수/총 약속 수)] * 100

 

그러나 성실도 점수 초기화 기능이 도입되고 난 후 성실도 점수 공식은 다음처럼 수정되었다.

[100 - (성실도 점수 초기화 이후 총 지각 수/성실도 점수 초기화 이후 총 약속 수)] * 100

 

즉, 초기화 이후의 약속들에 대해서만 성실도 점수를 계산해야 한다.

 

이를 구현하기 위한 방법이 크게 2가지가 있다고 생각하였다.

1. 초기화 시각을 User 테이블에 저장

초기화 시각을 User 테이블에 저장

 

2. 초기화 이후 약속 수와 초기화 이후 지각 수를 User 테이블에 저장

초기화 이후 약속 수와 초기화 이후 지각 수를 User 테이블에 저장

 

 

어느 방법으로 구현할지 판단하기 위해 각각의 장단점을 분석해보았다.

  초기화 시각을 저장 초기화 이후 약속,지각 수를 저장
성능 BAD
오버헤드가 큼. 성실도 점수를 계산할 때마다 Schedule 테이블에서 모든 데이터에 대해 약속시간과 초기화한 시간을 비교해야 함.
GOOD
성실도 점수를 계산할 때 저장한 두 값에 대해 나누기 연산만 하면 됨.
데이터 정합성/관리 GOOD BAD
지금 당장은 문제가 없으나 차후 기능이 추가되면 정합성 문제가 생길 수 있음.
(예: 지나간 약속 삭제기능이 생기고 만약 지나간 약속을 삭제하면 성실도 점수가 바뀐다면, 사용자가 지나간 약속을 삭제할 때마다 초기화 이후 약속, 지각수를 업데이트 해줘야함)

 

각각 장단점이 있었는데

Ontime은 약속관리/지각방지 앱으로 약속이 주인 어플리케이션이었다.

그만큼 약속 종료 -> 성실도 점수 업데이트가 잦을 것이라 판단하여

까다롭더라도 성실도 점수 계산 비용을 줄이는 것이 더 적합하다고 판단, 어쩌면 당연하다고 판단하였고

"초기화 이후 약속 수, 지각한 약속 수를 User 테이블에 저장"하는 방식으로 성실도 점수 업데이트 로직을 짜기로 결정하였다.

 

데이터 정합성/관리 측면은 차후 어떠한 기능이 추가되게 되면 트랜잭션과 같은 방식으로 정합성을 유지하도록 만들 수 있다고도 판단하였다. 실제 현업에서 이 두가지중 고른다면 당연히 후자(성능 우월, 정합성 아쉽)를 선택할 것 같다. 정합성을 만족시킬 방법은 까다로울뿐이지 분명 있을 것이기 때문이다.

User 테이블에 성실도점수 초기화 이후 약속, 지각 수 속성 추가

 

성실도 점수 초기화 및 업데이트 로직은 아래와 같다

 

먼저 회원가입 시 성실도 점수 -1, 초기화 이후 약속 수 0, 초기화 이후 지각 수 0 으로 세팅한다.

약속 종료 API가 호출될 때마다 성실도 점수가 -1인지 체크하고

-1이면 (초기화 이후 약속 수) ← 1, (초기화 이후 지각 수) ← 1 or 0, (성실도 점수) ← 초지수/초약수

else (초기화 이후 약속 수) += 1, (초기화 이후 지각 수) += 1 or 0

 

성실도 초기화 API를 호출하면 (초기화 이후 약속 수) ← 0, (초기화 이후 지각 수) ← 0, (성실도 점수) ← -1

 

 

이제 성실도 초기화 API를 구현하자.

서비스 계층에 

(초기화 이후 약속 수) ← 0, (초기화 이후 지각 수) ← 0, (성실도 점수) ← -1

위 로직만 구현하면 되므로 코드는 생략하겠다.

 

아래 두 커밋을 참고하자!

https://github.com/DevKor-github/OnTime-back/commit/7231fe7f175953e17d5f6cb59d05cbc91b090409

 

feat::성실도 초기화 기능 구현 · DevKor-github/OnTime-back@7231fe7

- 해당 API에 PUT요청 시 성실도점수 0으로 초기화

github.com

https://github.com/DevKor-github/OnTime-back/commit/697dab7ab6ab4a56bdb33a4f755be1bd962df1c1

 

"feat:: 유저 회원가입시 성실도 관련 속성 초기화" · DevKor-github/OnTime-back@697dab7

- 성실도 점수 -1로 초기화 - 초기화 이후 약속 수 0으로 초기화 - 초기화 이후 지각 수 0으로 초기화

github.com

 

 

2-4. 준비과정 종료 시 지각 시간, 성실도 점수 업데이트 API

사용자는 특정 약속의 준비시작버튼과 준비종료버튼을 누른다.

준비시작 버튼을 누르면 미리 설정해둔 준비과정들이 퍼센트로 보여지고 준비 종료버튼을 누르면 준비과정이 끝난다.

 

준비 종료버튼을 누르면 BE에서는 지각시간과 성실도 점수를 업데이트 해줘야한다.

지각을 하지 않았으면 지각시간을 0(분)으로, 지각을 했으면 지각시간을 N(분)으로 업데이트해야한다.(원래는 NULL임)

또한 성실도 점수 초기화 이후 약속수와 성실도 점수 초기화 이후 지각 수 그리고 성실도 점수를 업데이트해야 한다.

 

이 API야 말로 트랜잭션을 사용해야하는 API이다. 트랜잭션을 처리해야하는 이유는 다음과 같다.

1. 데이터 일관성

 - 약속 종료 직후 지각 시간, 약속 수, 지각 수, 성실도 점수 등 여러 테이블의 속성을 모두 업데이트해야 함.

 - 중간에 하나의 업데이트만 성공하고 다른 업데이트가 실패하면 데이터가 불일치 상태가 됨.

 - 이를 방지하려면 모든 업데이트가 성공적으로 수행되거나, 하나라도 실패하면 모두 롤백해야 함.

2. Atomicity

 - "준비 종료" 작업은 하나의 논리적 작업 단위임

 - 지각 시간과 성실도 점수 업데이트가 하나의 트랜잭션으로 묶여야 전체 작업이 Atomicity를 가짐

 

한 유저의 한 약속이 종료 될 때 실행되는 API이므로 동시성 제어는 고려할 필요 없다.

하지만 위 두 이유만으로 트랜잭션을 사용할 이유는 충분하다.

 

다음은 구현한 코드이다. 서비스계층을 주목하자.

 

PreparationController

@RestController
@RequestMapping("/preparation")
@RequiredArgsConstructor
public class PreparationController {
    private final UserAuthService userAuthService;
    private final ScheduleService scheduleService;
    private final UserService userService;
    private final PreparationService preparationService;


    @PutMapping("/finish") // 약속 준비 종료 이후 지각시간(Schedule 테이블), 성실도 점수(User 테이블) 업데이트
    public ResponseEntity<String> finishPreparation(
            HttpServletRequest request,
            @RequestBody FinishPreparationDto finishPreparationDto) {

        Long userId = userAuthService.getUserIdFromToken(request);

        preparationService.finishPreparation(userId, finishPreparationDto);

        return ResponseEntity.ok("해당 약속의 지각시간과 해당 유저의 성실도점수가 성공적으로 업데이트 되었습니다!");
    }
}

 

PreparationService

@Service
@RequiredArgsConstructor
public class PreparationService {
    private final UserService userService;
    private final ScheduleService scheduleService;

    @Transactional
    public void finishPreparation(Long userId, FinishPreparationDto finishPreparationDto) {
        scheduleService.updateLatenessTime(finishPreparationDto);
        userService.updatePunctualityScore(userId, finishPreparationDto.getLatenessTime());
    }
}

 

scheduleService

@Service
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
  
    //코드 생략//
  
    // 지각 시간 업데이트
    public void updateLatenessTime(FinishPreparationDto finishPreparationDto) {
        UUID scheduleId = finishPreparationDto.getScheduleId();
        Integer latenessTime = finishPreparationDto.getLatenessTime();

        Schedule schedule = scheduleRepository.findById(scheduleId)
                .orElseThrow(() -> new EntityNotFoundException("Schedule with ID " + scheduleId + " not found."));

        schedule.setLatenessTime(latenessTime);
        scheduleRepository.save(schedule);
    }

}

 

userService

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public void updatePunctualityScore(Long userId, Integer latenessTime) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("User not found"));

        if (user.getPunctualityScore() == (float) -1) {
            // 초기화 이후 첫 약속
            user.setScheduleCountAfterReset(1);
            user.setLatenessCountAfterReset(latenessTime > 0 ? 1 : 0);
        } else {
            // 기존 성실도 점수가 존재 -> 약속 수와 지각 수 업데이트
            user.setScheduleCountAfterReset(user.getScheduleCountAfterReset() + 1);
            if (latenessTime > 0) {
                user.setLatenessCountAfterReset(user.getLatenessCountAfterReset() + 1);
            }
        }

        // 성실도 점수 계산
        int totalSchedules = user.getScheduleCountAfterReset();
        int lateSchedules = user.getLatenessCountAfterReset();
        float punctualityScore = (1 - ((float) lateSchedules / totalSchedules)) * 100;

        user.setPunctualityScore(punctualityScore);
        userRepository.save(user);
    }
}

 

finishPreparationDto

@Getter
public class FinishPreparationDto {
    private UUID scheduleId;
    private Integer latenessTime;
}

 

보면 PreparationService에서 트랜잭션 처리해 ScheduleService의 지각시간 업데이트 메서드, UserService의 성실도 점수 업데이트 메서드가 트랜잭션으로 처리되는 것을 확인할 수 있다.

 

아래 두 커밋을 참고하자.

 

https://github.com/DevKor-github/OnTime-back/commit/9b7212ae30c2ea9fb653ecdcc809be06eb9e5523

 

feat::약속 준비 종료 이후 관련값 업데이트 기능 구현 · DevKor-github/OnTime-back@9b7212a

- 해당 API로 요청 보내면 지각시간(Schedule테이블), 성실도 점수(User테이블)가 업데이트 됨 - 기존 성실도 점수 업데이트 컨트롤러 삭제

github.com

https://github.com/DevKor-github/OnTime-back/commit/dfc1fd224e676913d0b166666cc877271577d7b5

 

mod:: 약속 준비 종료 이후 속성 업데이트 API 트랜잭션 처리 · DevKor-github/OnTime-back@dfc1fd2

- 기존 약속 준비 종료 이후 속성 업데이트하는 API는 컨트롤러에서 지각시간 업데이트, 성실도 점수 업데이트하는 메서드를 서비스 계층에서 각각 호출함 - 컨트롤러에서 서비스 계층의 하나의

github.com

 

 

3. 테스트(업데이트 필요)

1. 성실도 페이지 반환

서버 실행후 User, Schedule 데이터 삽입
성실도 페이지 API GET 요청시 성실도 점수, 지각히스토리, 약속히스토리 반환

 

 

2. 성실도 초기화

성실도 초기화 API PUT 요청
성실도 초기화 이후 db에서 user 조회

 

 

3. 성실도 업데이트

성실도 업데이트 API PUT 요청1
성실도 업데이트 된 모습1
성실도 업데이트 PUT 요청 2(3분 지각)
성실도 업데이트 된 모습2(지각내역이 반영됨)

 

 

4. 고민한 내용

4-1. API하나에 기능 하나 vs API하나에 기능 여러개

처음에 성실도 페이지 반환하는 기능을 성실도 점수와 지각 히스토리, 약속 히스토리를 하나의 API로 구현했었다.

FE와의 협업경험의 부족이 여기서 드러났던 것 같다. 이는 확장성 측면에서 절망적이다. 물론 단순 조회기능이라 이 경우에는 크게 지장이 없는데 일반적으로 API하나에 기능하나만 구현해야 차후 새로운 기능이 추가됐을 때 API 활용가능성이 높을 것이다. 또한 BE입장에서 가독성도 전자가 우월한 것 같다.
그리고 구글링을 하면서 이런 말을 보았다 "후자처럼 구현했으면 지금의 아마존은 없었을 것".
확장성있는 BE개발을하자!

 

4-2. 초기화시각 vs 초기화이후 약속수,지각수

4-3. 트랜잭션

2soon2soon