프로젝트를 진행하면서 CPU가 새벽에 꺼지는 문제가 발생했었다. 프로젝트 진행 중에 원인이 무엇인지 알아보기는 했지만 단지 CPU 성능이 좋지 않아 트래픽을 견뎌내지 못한 것이라고 판단했지만, 찾아보니 성능을 향상하기 위한 기능이 많아서 관련 기술 찾아본 내용을 정리했다. 

 

찾아보니 위 기술들은 MSA 환경에서 대량 트래픽의 유량 제어를 위해 사용할 수 있는 옵션들이었다. 

 

스케줄러 분산

분산된 환경에서 스케쥴러를 관리하는 방법이 꽤 복잡하고 어려운 것 같다. 

 

상황

Scalling 중이고, Scheduler 있는 서버인 경우 Multiple Scheduler Instances 발생한다. 이때 고민할 수 있는 2가지는 다음과 같다.

 

  1. Quartz 같은 분산 스케줄링 프레임워크 사용해서 여러 인스턴스에서의 스케줄러 실행 조정하기
    1. 한 번에 하나의 스케쥴러 인스턴스만 실행하게 함 
  2. SchedLock으로 번에 하나의 스케줄러 인스턴스만 실행되게 있음
    1. SpringBoot에서 Scheduler에 Lock을 거는 방법
    2. 한 번에 하나의 스케줄러 인스턴스만 실행하게 하기
    3. 스케줄러 인스턴스 실행 시 공유 자원에 잠금을 획득해 작업을 진행한다. 락이 없는 인스턴스는 대기한다

 

ShedLock

예약된 작업에 대한 분산 잠금을 제공하는 Spring Boot 라이브러다. 

잠금 데이터베이스 테이블과의 조합을 사용해 분산 환경에서 실행되는 애플리케이션의 여러 인스턴스에서 예약된 작업이 한 번만 실행되도록 한다.

 

  • 예약된 작업 ShedLock에 등록
  • 가용 테이블 확인 : ShedLock이 Lock DB 테이블 사용할 수 있는지 확인
    • Lock DB 테이블 사용할 수 없는 경우 경고 기록하고 작업 건너뜀
    • 사용할 수 있으면 예약된 작업의 cron 표현식을 기반으로 시간 계산
  • 잠금 획득 시도 : 시간상 실행할 수 있는 예약된 작업인 경우, 테이블에 레코드를 삽입해 예약된 작업에 대한 잠금 획득 시도
    • 삽입 성공 : SchedLock이 잠금 획득 성공
    • 삽입 실패 : ShedLock 잠금 획득 실패, 경고 기록하고 작업 건너뜀
  • 작업 완료: ShedLock은 Lock DB 테이블에서 잠금 레코드 삭제하고 잠금을 해제한다

 

잠금이 해제되기 전에 잠금을 보유한 응용 프로그램 인스턴스가 다운되면 ShedLock은 지정한 시간 초과 기간 후에 잠금을 해재해 다른 인스턴스가 차단되지 않도록 한다. 

 

 

Reference

https://developer-been.tistory.com/34

 

개발을 하면서 아직 해결되지 않은 고민이 있었는데, 바로 '데이터 삭제를 어떻게 할 것인가?'였다. 개발을 공부한지 얼마 안되었을 때는 '그냥 DB에 delete 쿼리 날려서 조건에 맞는 레코드 삭제하면 되는거 아닌가?'라고 생각했었다. 하지만 팀 프로젝트를 하거나 실무를 경험했을 때 생각보다 데이터 삭제에는 여러 상황이 얽혀있기 때문에 상황에 맞추어 삭제 방식을 선택해야함을 알게 되었다. 그 중 Soft Delete와 Hard Delete에 대해 공부한 내용이다.

 

1. Soft Delete란?

데이터베이스에서 데이터를 삭제하지 않고, 사용자 입장에서는 데이터에 접근할 수 없게 하는 방식을 의미한다. 보통 테이블에 is_deleted컬럼을 만들어 boolean값으로 데이터를 사용여부를 결정하는 방식이다. is_delete = 0이면 조회 가능, is_deleted = 1이면 조회 불가능한 것처럼 말이다. 조건 컬럼이 들어가므로 is_deleted의 값을 체크하는 쿼리가 들어가야 한다.

// 예시
update user
set is_delete = 1
where user_name = "hello"

soft 삭제 시 사용자는 데이터가 삭제된 것처럼 해당 데이터에 접근 불가능하지만, 애플리케이션 DB에는 데이터가 여전히 존재한다. 때문에 내부에서 데이터를 계속 사용해야할 가능성이 있다면 soft delete 방식을 선택하는 것이 적합할 수도 있다.

 

2. Hard Delete란?

데이터베이스에서도 데이터를 직접 삭제하는 방식을 의미한다. 더이상 사용하지 않는 데이터를 DB에 저장하는 것은 저장공간을 낭비하는 것일 수 있다. 이런 경우 직접 데이터를 삭제함으로써 저장 공간을 확보할 수 있다. 

delete *
from user
where user_name = "hello";

 

 

3. 데이터를 물리적으로 삭제를 주의해야하는 이유는 뭘까? 

1. 사용자, 검색 기록과 같이 더 나은 서비스 개선을 위해 분석해야하는 데이터들이 있다.

2. 혹시나 데이터를 다시 복구해야하는 상황이 발생할 수 있다. 

3. update 쿼리가 delete 쿼리보다 몇 ms 더 빠르다.

4. 데이터는 곧 돈이다. 정보화 시대에서 데이터는 굉장히 중요하다. 

 

그렇기 때문에 직접적으로 데이터를 데이터베이스에서 삭제하는 Hard Delete 보다는, 데이터베이스에 데이터는 존재하지만 사용자가 접근할 수 없게 하는 삭제 방법인 Soft Delete가 하나의 방법이 될 수 있다.

 

그러면 모든 경우에 다 Soft Delete를 하면 되는걸까? 이에 대한 답은 NO 다. 모든 선택이 Trade Off인 만큼 Soft Delete가 애플리케이션에 단점으로 작용할 수 있는 상황도 있다. 

 

4. Soft Delete의 단점

여러 방면으로 사용될 수 있는 방식이 Soft Delete이지만 단점도 존재하고, 여러 상황을 고려하지 않고 무조건 soft delete를 사용할 경우 나쁜 영향을 미칠 수 있다.

 

Soft delete의 단점은 다음과 같다.

1. 테이블 크기가 조금 커진다(컬럼 하나 추가).

2. 실제로 사용되지 않는 데이터가 존재하기에 저장 공간이 무거워질 수 있다. 

3. 매번 삭제여부를 고려해야한다. (삭제여부를 고려해 쿼리나 로직을 작성해야한다)

 

5.  고민

음,, 상황에 맞춰서 선택해야한다는 거는 맞는데 그럼 각각 어떤 경우에 해당할까? 한번 생각해봤다. 내가 지금까지 프로젝트를 하면서 삭제를 해야했던 경우는 사용자 삭제(회원탈퇴), 피드 삭제, 댓글/대댓글 삭제, 카테고리 삭제가 있는 것 같다.

 

일단 사용자 삭제 같은 경우는 사용자 정보가 중요하고, 추후에 데이터 분석 시에 사용될 가능성이 있어 Soft Delete를 하면 좋을 것 같다. 

카테고리 삭제 같은 경우도 가계부에서 사용되는 카테고리라서 추후 월말 분석이나 연말 분석 시 카테고리별로 수입/지출을 분석해야할 수 있어 Soft Delete로 진행하면 될 것 같다. 또 카테고리 정보는 그렇게 DB 공간도 차지하지 않을 것 같다. 

 

반면에 피드나 댓글/대댓글은 분석하기에 데이터가 유저에 비해 너무 방대하기도 하고, 게시물이 있을 때 그것을 보여주기위한 로직이 더 중요하기도 하고, 계속해서 DB에 적재해두면 저장 공간을 꽤 차지할 것 같아 Hard Delete가 적합할 수 있을 것 같다. 

 

6.  결론

결론은 상황에 맞추어서 Soft Delete와 Hard Delete를 선택해서 사용해야한다는 것이다. 

셀프 분석을 해보자면,

Soft Delete는 서비스 개선에 중요한 데이터이고 DB 용량을 크게 차지하지 않는 데이터에 적용하면 될 것 같고

Hard Delete는 만료기한이 있거나 용량 차지하는 게 서비스 개선에 기여하는 것보다 더 큰 경우 (공간 낭비 > 서비스 효용) 적용하면 될 것 같다. 

 

서비스에서 어떤 것을 중요하게 여기고, 더 나은 서비스를 위해 분석해야할 데이터가 무엇인지 고민해볼 필요가 있는 것 같다. 

 

 

Reference

https://www.becomebetterprogrammer.com/soft-delete-vs-hard-delete/#Are_Soft_Deletes_Bad

https://resilient-923.tistory.com/419

사용자가 채용공고에 지원하면 채용공고와 사용자 정보를 RequestBody로 넘기는 API이다 

사용자는 사용자대로, 채용공고는 채용공고대로 있기에 따로 Apply라는 객체로 만들어서 

사용자와 채용공고가 매치되게 했다. N : M 관계 구현을 의도한 것 같은데, 사실 사용자는 해당 채용공고에 한번만 지원가능하므로 사실상 1 : 1 을 구현한 건가싶다 

 

- User

위치 : /domain/user

@Table
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String userName;

    @Builder
    public User(String userName) {
        this.userName = userName;
    }

    public static User of(String userName) {
        User user = User.builder()
                .userName(userName)
                .build();

        return user;
    }
}

 

- UserRepository

위치 : /domain/user

public interface UserRepository extends JpaRepository<User, Long> {
}

 

- UserService

위치 : /service/user

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public void createUser(CreateUserRequest request) {
        User created = User.of(request.getUserName());

        userRepository.save(created);
    }
}

 

- UserController

위치 : /web/user

@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @PostMapping("/create")
    public ApiResponse<String> createUser(@RequestBody CreateUserRequest request) {
        userService.createUser(request);
        return ApiResponse.SUCCESS;
    }
}

 

- Apply

위치 : /domain/apply

@Table
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Apply {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "USER_ID")
    private User user;

    @OneToOne
    @JoinColumn(name = "RECRUITMENT_ID")
    private Recruitment recruitment;

    @Builder
    public Apply(User user, Recruitment recruitment) {
        this.user = user;
        this.recruitment = recruitment;
    }

    public static Apply of(User user, Recruitment recruitment) {
        Apply apply = Apply.builder()
                .user(user)
                .recruitment(recruitment)
                .build();

        return apply;
    }
}

 

- ApplyRepository

public interface ApplyRepository extends JpaRepository<Apply, Long> {

    Optional<Apply> findByUser(User user);
}

 

- ApplyService

위치 : /domain/apply

@Service
@RequiredArgsConstructor
public class ApplyService {

    private final RecruitmentRepository recruitmentRepository;
    private final UserRepository userRepository;
    private final ApplyRepository applyRepository;

    public void createApply(CreateApplyRequest request) throws Exception {
        Optional<Recruitment> recruitment = recruitmentRepository.findById(request.getRecruitId());
        Optional<User> user = userRepository.findById(request.getUserId());

        Optional<Apply> apply = applyRepository.findByUser(user.get());
        if (apply.isPresent()) {
            // 사용자는 한 채용공고에 한번만 지원할 수 있다
            throw new Exception();
        }

        Apply created = Apply.of(user.get(), recruitment.get());
        applyRepository.save(created);
    }
}

 

- ApplyController

위치 : /web/apply

@RestController
@RequiredArgsConstructor
@RequestMapping("/apply")
public class ApplyController {

    private final ApplyService applyService;

    @PostMapping("/create")
    public ApiResponse<String> createApply(@RequestBody CreateApplyRequest request) throws Exception {
        applyService.createApply(request);
        return ApiResponse.SUCCESS;
    }
}

 

Postman

 

RequestBody

{
    "recruitId" : 1,
    "userId" : 1
}

 

ResponseBody

{
    "data": null
}

 

'개인프로젝트 > 과제' 카테고리의 다른 글

6. 채용 상세 페이지 조회 API  (0) 2022.08.25
5-2. 채용공고 검색 API (가산점 요소)  (0) 2022.08.25
5-1. 채용공고 목록 API  (0) 2022.08.24
4. 삭제 API  (0) 2022.08.23
3. 수정 API  (0) 2022.08.23

채용 공고에서 특정 객체를 선택했을 때 상세 정보를 조회하는 API이다 

여기서 가산점을 주는 요소는 채용공고를 올린 회사가 올린 다른 채용공고의 id 리스트도 같이 반환하는 것이다 

 

회사 엔티티와 관련 로직이 필요해 추가했다 

- Company

위치  : /domain/company

@Table
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Company {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String companyName;
    private String country;
    private String region;


    @Builder
    public Company(String companyName, String country, String region) {
        this.companyName = companyName;
        this.country = country;
        this.region = region;
    }

    public static Company of(String companyName, String country, String region) {
        Company company = Company.builder()
                .companyName(companyName)
                .country(country)
                .region(region)
                .build();

        return company;
    }
}

-CompanyRepository

위치  : /domain/company

public interface CompanyRepository extends JpaRepository<Company, Long> {
}

- CompanyService

위치 : /service/company

@Service
@RequiredArgsConstructor
public class CompanyService {

    private final CompanyRepository companyRepository;

    public void createCompany(CreateCompanyRequest request) {

        Company created = Company.of(request.getCompanyName(), request.getCountry(), request.getRegion());

        companyRepository.save(created);
    }
}

 

- CompanyController

위치 : /web/company

@RestController
@RequiredArgsConstructor
@RequestMapping("/company")
public class CompanyController {

    private final CompanyService companyService;

    @PostMapping("/create")
    public ApiResponse<String> createCompany(@RequestBody CreateCompanyRequest request) {
        companyService.createCompany(request);
        return ApiResponse.SUCCESS;
    }
}

 

- getRecruitDetail()

위치 : /web/recruitment/RecruitmentController.class

@GetMapping("/detail/{id}")
public ApiResponse<GetRecruitDetailResponse> getRecruitDetail(@PathVariable Long id) {
    GetRecruitDetailResponse response = recruitmentService.getRecruitDetail(id);
    return ApiResponse.success(response);
}

 

-getRecruitDetail()

위치 : /service/recruitment/RecruitmentService.class

public GetRecruitDetailResponse getRecruitDetail(Long id) {
        Optional<Recruitment> findRecruit = recruitmentRepository.findById(id);
        Company company = findRecruit.get().getCompany();
        Long companyId = company.getId();

        List<Long> companyRecruits = new ArrayList<>();
        List<Recruitment> recruits = recruitmentRepository.findByCompanyId(companyId);
        for (Recruitment recruit : recruits) {
            if (!recruit.getId().equals(id)) {
                companyRecruits.add(recruit.getId());
            }
        }

        GetRecruitDetailResponse response = GetRecruitDetailResponse.of(company, findRecruit.get(), companyRecruits);

        return response;
    }

 

- GetRecruitDetailResponse

@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GetRecruitDetailResponse {

    private Long recruitId;
    private String companyName;
    private String country;
    private String region;
    private String position;
    private Integer reward;
    private String techInfo;
    private String contents;
    private List<Long> companyRecruits = new ArrayList<>();

    @Builder
    public GetRecruitDetailResponse(Long recruitId, String companyName, String country, String region, String position, Integer reward, String techInfo, String contents, List<Long> companyRecruits) {
        this.recruitId = recruitId;
        this.companyName = companyName;
        this.country = country;
        this.region = region;
        this.position = position;
        this.reward = reward;
        this.techInfo = techInfo;
        this.contents = contents;
        this.companyRecruits = companyRecruits;
    }

    public static GetRecruitDetailResponse of(Company company, Recruitment recruitment, List<Long> companyRecruits) {
        GetRecruitDetailResponse response = GetRecruitDetailResponse.builder()
                .recruitId(recruitment.getId())
                .companyName(company.getCompanyName())
                .country(company.getCountry())
                .region(company.getRegion())
                .position(recruitment.getPosition())
                .reward(recruitment.getReward())
                .techInfo(recruitment.getTechInfo())
                .contents(recruitment.getContents())
                .companyRecruits(companyRecruits)
                .build();

        return response;
    }
}

Postman

RequestParam

ResponseBody

여기에서 조회한 채용공고까지 중복으로 포함되는데 이 부분은 수정해야할 것 같다 

{
    "data": {
        "recruitId": 1,
        "companyName": "wanted",
        "country": "korea",
        "region": "seoul",
        "position": "Backend Developer 1",
        "reward": 125000000,
        "techInfo": "Java",
        "contents": "now wanted is hiring for ,,,",
        "companyRecruits": [
            2,
            3
        ]
    }
}
{
    "data": {
        "recruitId": 5,
        "companyName": "naver",
        "country": "korea",
        "region": "seoul",
        "position": "backend junior developer",
        "reward": 25000000,
        "techInfo": "Java",
        "contents": "now naver is hiring for,,,",
        "companyRecruits": [
            4
        ]
    }
}
 

 

'개인프로젝트 > 과제' 카테고리의 다른 글

7. 사용자가 채용공고에 지원 API (가산점 요소)  (0) 2022.08.25
5-2. 채용공고 검색 API (가산점 요소)  (0) 2022.08.25
5-1. 채용공고 목록 API  (0) 2022.08.24
4. 삭제 API  (0) 2022.08.23
3. 수정 API  (0) 2022.08.23

+ Recent posts