πŸ“„ 주주총회 μ „μž νˆ¬ν‘œ μ‹œμŠ€ν…œ

μ•ˆκ±΄μ„ κ΄€λ¦¬ν•˜κ³  νˆ¬ν‘œλ₯Ό μˆ˜ν–‰ν•  수 μžˆλŠ” API μ„œλ²„



μ‚¬μš©ν•œ ν”„λ ˆμž„μ›Œν¬ 및 라이브러리



  • SpringBoot(3.0.1) + Java(17)

    • Spring Web
    • Spring Data JPA
    • Spring Security
    • Spring validation
    • Lombok

  • MySQL(DB)



λ¬Έμ„œ



API λͺ…μ„Έ

ERD

회고



λ™μž‘ 방식



Rest API μ„œλ²„λ‘œμ„œ Http Method 와 URL 을 톡해 μ„œλ²„μ—κ²Œ μš”μ²­, JSON ν˜•νƒœμ˜ 응닡 μˆ˜μ‹ 


image

μ„œλ²„ λ‚΄ 컨트둀러, μ„œλΉ„μŠ€, 리포지토리 뢄리

  • Controller:    μ›Ή MVC의 컨트둀러 μ—­ν• (REST controller) + ν΄λΌμ΄μ–ΈνŠΈμ—κ²Œ 전달할 DTO둜의 λ³€ν™˜
  • Service:   λ„메인을 λ‹€λ£¨λŠ” λΉ„μ¦ˆλ‹ˆμŠ€ 둜직
  • Repository:    데이터 λ² μ΄μŠ€μ— μ ‘κ·Όν•˜μ—¬ 도메인 데이터λ₯Ό κ°€μ Έμ˜€κ³ , μ €μž₯함
  • Domain:    μ•ˆκ±΄, μ‚¬μš©μž, νˆ¬ν‘œ 도메인 객체


μš”κ΅¬ 사항 κ΅¬ν˜„ 방식



1. μ‹œμŠ€ν…œμ€ 인증을 톡해 μΈκ°€λœ μ‚¬μš©μžλ§Œ μ ‘κ·Όν•  수 μžˆμ–΄μ•Ό ν•˜κ³ ,
주주와 κ΄€λ¦¬μžκ°€ 역할을 기반으둜 ν•  수 μžˆλŠ” 행동이 달라져야 ν•œλ‹€

http
        .requestMatchers("/api/sign-up").permitAll()
        .requestMatchers("/api/login").permitAll()
        .requestMatchers(HttpMethod.POST, "/api/agendas").hasRole(Role.ADMIN.name())
        .requestMatchers(HttpMethod.DELETE, "/api/agendas/**").hasRole(Role.ADMIN.name())
        .requestMatchers("/api/agendas/*/terminate").hasRole(Role.ADMIN.name())
        .requestMatchers(HttpMethod.POST, "/api/votes").hasRole(Role.USER.name())
        .anyRequest().authenticated()

μœ„ μ‹œνλ¦¬ν‹° 섀정을 톡해 μΈμ¦λ˜μ§€ μ•Šμ€ μ‚¬μš©μžλŠ” νšŒμ› κ°€μž…κ³Ό 둜그인 URLμ—λ§Œ μš”μ²­μ„ 보낼 수 있으며,
인증된 μ‚¬μš©μžλ„ μΈκ°€λ˜μ§€ μ•Šμ€ URLμ—λŠ” μ ‘κ·Ό λΆˆν—ˆ



2. μ‹œμŠ€ν…œμ€ μ•ˆκ±΄μ΄λΌκ³  λΆˆλ¦¬λŠ” ν˜„μ•ˆμ— λŒ€ν•΄ μ°¬μ„±, λ°˜λŒ€ λ˜λŠ” 기ꢌ μ˜μ‚¬λ₯Ό ν‘œλͺ…ν•  수 μžˆλŠ” νˆ¬ν‘œ κΈ°λŠ₯을 μ œκ³΅ν•΄μ•Ό ν•œλ‹€.
μ•ˆκ±΄μ€ κ΄€λ¦¬μžκ°€ μƒμ„±ν•˜κ±°λ‚˜ μ‚­μ œν•  수 μžˆλ‹€

@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/agendas")
public SimpleAgendaResponseDto create(@RequestBody @Valid AgendaCreateRequestDto agendaCreateRequestDto) {
    Agenda savedAgenda = agendaService.createAgenda(agendaCreateRequestDto);
    return AgendaResponseFactory.getDto(Role.ADMIN, savedAgenda);
}

@DeleteMapping("/agendas/{id}")
public void delete(@PathVariable("id") Long agendaId) {
    agendaService.removeAgenda(agendaId);
}

@PostMapping("/votes")
public SimpleAgendaResponseDto vote(@RequestBody @Valid VoteRequestDto voteRequestDto) {
    Agenda agenda = agendaService.vote(
        SecurityUtil.getCurrentUserId(),
        voteRequestDto.getAgendaId(),
        voteRequestDto.getType(),
        voteRequestDto.getQuantity());
    return AgendaResponseFactory.getDto(Role.USER, agenda);
}

μ•ˆκ±΄μ˜ 생성, μ‚­μ œ 및 νˆ¬ν‘œ κΈ°λŠ₯은 μœ„ 컨트둀러λ₯Ό 톡해 μš”μ²­ 및 μˆ˜ν–‰ν•  수 있음

@Transactional
public Agenda createAgenda(AgendaCreateRequestDto agendaCreateRequestDto) {
    return agendaRepository.save(Agenda.of(agendaCreateRequestDto));
}

@Transactional
public void removeAgenda(long agendaId) {
    agendaRepository.delete(agendaRepository.findById(agendaId).orElseThrow());
}

@Transactional
public Agenda vote(String userId, Long agendaID, VoteType type, int quantity) {
    User user = userService.getUser(userId);
    Agenda agenda = agendaRepository.findByIdWithLock(agendaID).orElseThrow();

    VotingSystem votingSystem = votingSystemFactory.makeVotingSystem(agenda);
    votingSystem.vote(user, agenda, type, quantity);
    return agenda;
}

μ»¨νŠΈλ‘€λŸ¬μ—μ„œ μ„œλΉ„μŠ€λ‘œ 둜직 μˆ˜ν–‰μ„ μœ„μž„ν•˜κ³ , μ„œλΉ„μŠ€μ—μ„  JPA Repositoryλ₯Ό 톡해 μ•ˆκ±΄μ„ 생성, μ‚­μ œν•¨
νˆ¬ν‘œ λ˜ν•œ μ•ˆκ±΄μ˜ νˆ¬ν‘œ 방식에 λ”°λ₯Έ νˆ¬ν‘œ μ‹œμŠ€ν…œμ„ 톡해 νˆ¬ν‘œλ₯Ό μˆ˜ν–‰ν•  수 있음



3. μ‹œμŠ€ν…œμ€ μ‚¬μš©μžλ“€μ—κ²Œ μ•ˆκ±΄ λͺ©λ‘μ„ μ‘°νšŒν•  수 μžˆλŠ” APIλ₯Ό μ œκ³΅ν•΄μ•Ό ν•˜κ³ ,
ν•΄λ‹Ή μ•ˆκ±΄μ΄ ν˜„μž¬ νˆ¬ν‘œ 쀑인지 여뢀와 아직 μ§„ν–‰λ˜μ§€ μ•Šμ€ 경우 λ‹€μŒ νˆ¬ν‘œ 일정을 확인할 수 μžˆμ–΄μ•Ό ν•œλ‹€

@GetMapping("/agendas")
public Page<AllAgendaResponseDto> agendas(
        @PageableDefault(sort = "startsAt", direction = Sort.Direction.ASC) Pageable pageable) {
    return agendaService.getAgendas(pageable).map(AllAgendaResponseDto::from);
}

μ•ˆκ±΄ λͺ©λ‘μ„ μ‘°νšŒν•  경우 νŽ˜μ΄μ§• 된 μ•ˆκ±΄ λͺ©λ‘μ„ 받을 수 있으며,
ν΄λΌμ΄μ–ΈνŠΈκ°€ μ›ν•˜λŠ” μ •λ ¬ 방식과 λ°©ν–₯을 κΈ°μ€€μœΌλ‘œ μ •λ ¬ν•  수 있음 (νˆ¬ν‘œ μ‹œμž‘ μ‹œκ° λΉ λ₯Έ 순 κΈ°λ³Έ)

λ˜ν•œ νˆ¬ν‘œ 별 ID 및 제λͺ©, νˆ¬ν‘œ κ°€λŠ₯ μ‹œκ°„μ„ 확인할 수 있음 (νˆ¬ν‘œ μ‹œμž‘ μ‹œκ° ~ νˆ¬ν‘œ μ’…λ£Œ μ‹œκ°)



4. νˆ¬ν‘œλŠ” κ΄€λ¦¬μžκ°€ κ²Œμ‹œν•˜κ±°λ‚˜ μ’…λ£Œν•  수 μžˆλ‹€.
νˆ¬ν‘œλŠ” κ΄€λ¦¬μžκ°€ 직접 μ’…λ£Œν•  μˆ˜λ„ μžˆμ§€λ§Œ νˆ¬ν‘œλ₯Ό κ²Œμ‹œν•˜λŠ” μ‹œμ μ— μ’…λ£Œ μ‹œκ°„μ„ ν†΅λ³΄ν•˜μ—¬ μ‹œμŠ€ν…œμ΄ ν•΄λ‹Ή μ‹œκ°„μ΄ μ§€λ‚œ 후에 νˆ¬ν‘œλ₯Ό μ’…λ£Œ μ‹œν‚¬ 수 μžˆμ–΄μ•Ό ν•œλ‹€

@Column(nullable = false)
private LocalDateTime startsAt;

@Setter
@Column(nullable = false)
private LocalDateTime endsAt;

μ•ˆκ±΄λŠ” 진행쀑 ν˜Ήμ€ μ’…λ£Œ 와 같은 μƒνƒœλ₯Ό 가지고 μžˆμ§€ μ•Šκ³ ,
νˆ¬ν‘œ ν˜Ήμ€ 쑰회λ₯Ό μš”μ²­ν•œ μ‹œμ κ³Ό μ§€μ •ν•œ μ’…λ£Œ μ‹œκ°μ„ λΉ„κ΅ν•˜μ—¬ μœ νš¨μ„± 및 μ’…λ£Œ μ—¬λΆ€λ₯Ό 결정함

@PatchMapping("/agendas/{id}/terminate")
public SimpleAgendaResponseDto end(@PathVariable("id") Long agendaId) {
    Agenda agenda = agendaService.terminate(agendaId);
    return AgendaResponseFactory.getDto(Role.ADMIN, agenda);
}

@Transactional
public Agenda terminate(long agendaId) {
    Agenda agenda = agendaRepository.findById(agendaId).orElseThrow();
    agenda.setEndsAt(LocalDateTime.now());
    return agenda;
}

λ•Œλ¬Έμ— μ’…λ£Œ μš”μ²­μ€ ν•΄λ‹Ή μ•ˆκ±΄μ˜ μ’…λ£Œ μ‹œκ°μ„ μ’…λ£Œλ₯Ό μš”μ²­ν•œ μ‹œκ°μœΌλ‘œ λ³€κ²½ν•˜λŠ” λ°©μ‹μœΌλ‘œ λ™μž‘



5. μ˜κ²°κΆŒμ€ μ•ˆκ±΄μ— νˆ¬ν‘œν•  수 μžˆλŠ” νˆ¬ν‘œκΆŒμ˜ 개수둜 ν•œ λͺ…μ˜ μ£Όμ£ΌλŠ” μ—¬λŸ¬ 개의 μ˜κ²°κΆŒμ„ κ°€μ§ˆ 수 μžˆλ‹€

private Integer voteRights;

μ‚¬μš©μž μ—”ν‹°ν‹° λ‚΄ μ½”λ“œλ‘œ, μ‚¬μš©μžλŠ” μ§€μ •λœ 개수만큼의 μ˜κ²°κΆŒμ„ κ°€μ§ˆ 수 있으며,
μ‚¬μš©μž 생성 μ‹œ μ§€μ •λ˜λŠ” κ°’



6. 진행 쀑인 νˆ¬ν‘œμ— μ˜κ²°κΆŒμ„ 행사할 λ•Œ, μ£Όμ£ΌλŠ” λ³΄μœ ν•œ μ˜κ²°κΆŒλ³΄λ‹€ 적게 행사할 수 μžˆλ‹€

private void validateUser(User user, int quantity) {
    if (user.getVoteRights() < quantity) {
        throw new InvalidVoteException(UserErrorCode.EXCEED_VOTE);
    }
}

νˆ¬ν‘œ μ‹œμŠ€ν…œμ˜ κ΅¬ν˜„μ²΄ μ½”λ“œ λ‚΄ μœ νš¨μ„±μ„ κ²€μ¦ν•˜λŠ” κ³Όμ • 쀑,
μ‚¬μš©μžκ°€ 가진 의결ꢌ의 κ°œμˆ˜μ™€ ν–‰μ‚¬ν•˜λ €λŠ” 의결ꢌ의 개수λ₯Ό λΉ„κ΅ν•˜μ—¬ μœ νš¨ν•œμ§€ κ²€μ‚¬ν•˜λŠ” 뢀뢄이
ν¬ν•¨λ˜μ–΄ 있음



7. μ•ˆκ±΄μ€ κ²½μ˜μ§„μ˜ μš”κ΅¬μ— 따라 총 2 가지 νˆ¬ν‘œ 방식을 지원해야 ν•œλ‹€. 첫 λ²ˆμ§ΈλŠ” 의결ꢌ μ„ μ°©μˆœ μ œν•œ 경쟁이고 λ‚˜λ¨Έμ§€λŠ” μ œν•œμ΄ μ—†λŠ” 방식이닀

public enum AgendaType {

    NORMAL,
    LIMITED
}

νˆ¬ν‘œ 방식(일반, μ„ μ°©μˆœ)을 Enum 으둜 μ •μ˜ν•˜κ³  μ•ˆκ±΄ 엔티티에 ν¬ν•¨μ‹œμΌ°μŒ



8. 의결ꢌ μ„ μ°©μˆœ μ œν•œ κ²½μŸμ€ νˆ¬ν‘œμ— μ°Έμ—¬ν•˜λŠ” μ„ μ°©μˆœμœΌλ‘œ 10개의 의결ꢌ만 νˆ¬ν‘œμ— λ°˜μ˜ν•˜λŠ” 방식이닀.
예λ₯Ό λ“€λ©΄ A μ£Όμ£ΌλŠ” 3개의 의결ꢌ이 있고, B μ£Όμ£ΌλŠ” 8개의 의결ꢌ이 μžˆμ„ λ•Œ,
A와 Bκ°€ μˆœμ„œλŒ€λ‘œ νˆ¬ν‘œμ— μ°Έμ—¬ν•œλ‹€λ©΄ AλŠ” 3개의 μ˜κ²°κΆŒμ„ λͺ¨λ‘ 행사할 수 있고, BλŠ” 8개 쀑 7개의 의결ꢌ만 행사할 수 μžˆλ‹€.
이후에 μ°Έκ°€ν•œ μ£Όμ£ΌλŠ” 의결ꢌ 행사가 λΆˆκ°€λŠ₯ν•˜λ‹€

@Override
public void vote(User user, Agenda agenda, VoteType type, int quantity) {
    quantity = Math.min(quantity, MAX_VOTE_COUNT - agenda.getTotalRights());
    super.vote(user, agenda, type, quantity);
    if (agenda.getTotalRights() >= MAX_VOTE_COUNT) {
        agenda.setEndsAt(LocalDateTime.now());
    }
}

μœ„λŠ” μ„ μ°©μˆœ νˆ¬ν‘œ μ‹œμŠ€ν…œμ˜ νˆ¬ν‘œ μˆ˜ν–‰ λ©”μ„œλ“œλ‘œ,
10κ°œκ°€ λ„˜λŠ” νˆ¬ν‘œ μš”μ²­μ΄ λ“€μ–΄μ˜¬ 경우, 10κ°œκΉŒμ§€λ§Œ λ°˜μ˜λ˜λŠ” 개수의 의결ꢌ만 받아듀이도둝 μ„€κ³„ν•˜μ˜€μŒ

λ˜ν•œ, μ„ μ°©μˆœ νˆ¬ν‘œμ— 반영된 ν‘œκ°€ 10ν‘œκ°€ λ˜μ—ˆμ„ λ•Œ, νˆ¬ν‘œκ°€ μ’…λ£Œλ˜λ„λ‘ κ΅¬ν˜„



9. μ œν•œ μ—†λŠ” 방식은 의결ꢌ의 μ œν•œ 없이 λͺ¨λ“  μ£Όμ£Όκ°€ μžμ‹ μ΄ 가진 λͺ¨λ“  μ˜κ²°κΆŒμ„ μ•ˆκ±΄μ— νˆ¬ν‘œν•  수 μžˆλ‹€

private void validateUser(User user, int quantity) {
    if (user.getVoteRights() < quantity) {
        throw new InvalidVoteException(UserErrorCode.EXCEED_VOTE);
    }
}

μš”κ΅¬μ‚¬ν•­ 6번과 λ™μΌν•œ λ°©μ‹μœΌλ‘œ, 일반 νˆ¬ν‘œμ˜ 경우 μ‚¬μš©μžκ°€ μ†Œμ§€ν•œ μ˜κ²°κΆŒμ— μ΄ˆκ³Όλ˜μ§€ μ•ŠμœΌλ©΄ μœ νš¨ν•œ νˆ¬ν‘œλ‘œ 인정됨



10. μ‹œμŠ€ν…œμ€ νˆ¬ν‘œ κ²°κ³Όλ₯Ό 투λͺ…ν•˜κ²Œ 확인할 수 μžˆλ„λ‘ νˆ¬ν‘œκ°€ μ™„λ£Œλœ μ•ˆκ±΄μ— λŒ€ν•΄ κ·Έ λͺ©λ‘κ³Ό μ°¬μ„±, λ°˜λŒ€, 기ꢌ의 숫자λ₯Ό 확인할 수 μžˆλŠ” APIλ₯Ό μ œκ³΅ν•΄μ•Ό ν•œλ‹€.
κ΄€λ¦¬μžλŠ” ν•΄λ‹Ή APIλ₯Ό 톡해 μ–΄λ–€ μ‚¬μš©μžκ°€ ν•΄λ‹Ή μ•ˆκ±΄μ— μ°¬μ„±, λ°˜λŒ€, 기ꢌ μ˜μ‚¬ ν‘œλͺ…을 ν–ˆλŠ”μ§€ 여뢀와 μ–Όλ§ˆλ‚˜ λ§Žμ€ μ˜κ²°κΆŒμ„ ν–‰μ‚¬ν–ˆλŠ”μ§€ 확인할 수 μžˆμ–΄μ•Ό ν•œλ‹€

public class AgendaResponseFactory {

  public static SimpleAgendaResponseDto getDto(Role role, Agenda agenda) {
    if (LocalDateTime.now().isAfter(agenda.getEndsAt())) {
      return new ResultAgendaResponseDto().from(agenda);
    }
    return switch (role) {
      case USER -> new SimpleAgendaResponseDto().from(agenda);
      case ADMIN -> new ResultAgendaResponseDto().from(agenda);
    };
  }

  public static SimpleAgendaResponseDto getDto(Agenda agenda, List<Vote> votes) {
    return new DetailResultAgendaResponse(votes).from(agenda);
  }
}

단일 μ•ˆκ±΄ μ‘°νšŒλŠ” 쑰건에 따라 크게 3κ°€μ§€λ‘œ λΆ„λ₯˜ν•  수 있음

  • νˆ¬ν‘œκ°€ 진행쀑일 경우
    • μ‚¬μš©μž: μ•ˆκ±΄ ID, μ•ˆκ±΄ 제λͺ©, νˆ¬ν‘œ κ°€λŠ₯ μ‹œκ°„
    • κ΄€λ¦¬μž: μ•ˆκ±΄ ID, μ•ˆκ±΄ 제λͺ©, νˆ¬ν‘œ κ°€λŠ₯ μ‹œκ°„, ν˜„μž¬ ν‘œ μ’…λ₯˜ 별 개수(μ°¬μ„±, λ°˜λŒ€, 무효)
  • νˆ¬ν‘œκ°€ μ’…λ£Œλ˜μ—ˆμ„ 경우
    • μ‚¬μš©μž: μ•ˆκ±΄ ID, μ•ˆκ±΄ 제λͺ©, νˆ¬ν‘œ κ°€λŠ₯ μ‹œκ°„, κ²°κ³Ό ν‘œ μ’…λ₯˜ 별 개수(μ°¬μ„±, λ°˜λŒ€, 무효)
    • κ΄€λ¦¬μž: μ•ˆκ±΄ ID, μ•ˆκ±΄ 제λͺ©, νˆ¬ν‘œ κ°€λŠ₯ μ‹œκ°„, κ²°κ³Ό ν‘œ μ’…λ₯˜ 별 개수(μ°¬μ„±, λ°˜λŒ€, 무효), μ‚¬μš©μž 별 νˆ¬ν‘œ 기둝

각 응닡에 μ€‘λ³΅λ˜λŠ” 뢀뢄이 μžˆμœΌλ―€λ‘œ 상속을 ν™œμš©ν•˜μ—¬ 각 응닡을 κ΅¬ν˜„ν•˜μ˜€μŒ

public class ResultAgendaResponseDto extends SimpleAgendaResponseDto {

    @JsonProperty
    private int positiveRights;

    @JsonProperty
    private int negativeRights;

    @JsonProperty
    private int invalidRights;

    public void setFrom(Agenda agenda) {
        super.setFrom(agenda);
        this.positiveRights = agenda.getPositiveRights();
        this.negativeRights = agenda.getNegativeRights();
        this.invalidRights = agenda.getInvalidRights();
    }

    @Override
    public ResultAgendaResponseDto from(Agenda agenda) {
        setFrom(agenda);
        return this;
    }
}
@RequiredArgsConstructor
public class DetailResultAgendaResponse extends ResultAgendaResponseDto {

    private final List<Vote> votes;

    @JsonProperty
    private List<VoteResponseDto> voteResults;

    @Override
    public DetailResultAgendaResponse from(Agenda agenda) {
        this.voteResults = votes.stream().map(VoteResponseDto::from).toList();
        super.setFrom(agenda);
        return this;
    }
}



11. μ‹œμŠ€ν…œμ€ νˆ¬ν‘œ κ²°κ³Όκ°€ μ‘°μž‘λ˜μ§€ μ•ŠμŒμ„ 증λͺ…ν•˜κΈ° μœ„ν•΄ 둜그λ₯Ό ν†΅ν•œ 감사λ₯Ό 지원해야 ν•œλ‹€.
이λ₯Ό μœ„ν•΄ νŠΉμ • μ‚¬μš©μžκ°€ νˆ¬ν‘œν•œ κ²°κ³Όλ₯Ό μ‹€μ‹œκ°„μœΌλ‘œ 기둝해야 ν•œλ‹€

public void vote(User user, Agenda agenda, VoteType type, int quantity) {
    validate(user, agenda, quantity);
    agenda.vote(type, quantity);
    log.info("νˆ¬ν‘œμž={}, μ•ˆκ±΄={}, ν‘œ μ’…λ₯˜={}, ν‘œ 개수={}", user.getName(), agenda.getTitle(), type.name(), quantity);
    voteRepository.save(Vote.create(user, agenda, type, quantity));
}

νˆ¬ν‘œ μ‹œμŠ€ν…œμ„ 톡해 νˆ¬ν‘œλ₯Ό μˆ˜ν–‰ν•  경우, ν‘œλ₯Ό ν–‰μ‚¬ν•˜λŠ” μ‚¬μš©μžμ˜ 이름과 μ•ˆκ±΄μ˜ 제λͺ©, ν‘œ μ’…λ₯˜(μ°¬μ„±, λ°˜λŒ€, 무효), ν–‰μ‚¬ν•œ 의결ꢌ κ°œμˆ˜κ°€
νƒ€μž„μŠ€νƒ¬ν”„μ™€ ν•¨κ»˜ μ½˜μ†”μ— 기둝됨



12. μš”κ΅¬ 사항 8λ²ˆμ— ν•΄λ‹Ήν•˜λŠ” 의결ꢌ μ„ μ°©μˆœ μ œν•œ 경쟁 방식은 μ—¬λŸ¬ μ£Όμ£Όκ°€ λ™μ‹œμ— μ œν•œ κ²½μŸμ— μ°Έμ—¬ν•˜λ”λΌλ„ 정상 λ™μž‘ν•¨μ„ 보μž₯ν•΄μ•Ό ν•œλ‹€.
이λ₯Ό ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό 톡해 검증이 κ°€λŠ₯ν•΄μ•Ό ν•œλ‹€

public interface AgendaRepository extends JpaRepository<Agenda, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Agenda a where a.ID=:agendaId")
    Optional<Agenda> findByIdWithLock(@Param("agendaId") Long agendaId);
}

일반 νˆ¬ν‘œμ™€ μ„ μ°©μˆœ νˆ¬ν‘œ λͺ¨λ‘ λ™μ‹œμ— μš”μ²­μ΄ 전달될 수 μžˆμœΌλ―€λ‘œ,

νˆ¬ν‘œλ₯Ό μœ„ν•΄ μ„œλΉ„μŠ€μ—μ„œ ν•΄λ‹Ή μ•ˆκ±΄μ„ μ‘°νšŒν•΄μ˜¬ λ•Œ λ‹€λ₯Έ μš”μ²­μ€ ν•΄λ‹Ή μ•ˆκ±΄μ˜ μˆ˜μ •μ΄ 끝날 λ•Œ κΉŒμ§€ 기닀리도둝
비관적 쓰기락을 걸도둝 κ΅¬ν˜„ν•˜μ˜€μŒ

@DisplayName("λ™μ‹œμ— μ„ μ°©μˆœ νˆ¬ν‘œλ₯Ό μˆ˜ν–‰ν•΄λ„ κ°€μž₯ λΉ λ₯Έ 10ν‘œλ§Œ λ°˜μ˜λœλ‹€.")
@Test
void concurrentLimitedVote() throws InterruptedException {
        // given
        AgendaCreateRequestDto agendaDto = new AgendaCreateRequestDto("사내 νœ΄κ²Œμ‹œμ„€ 증진", AgendaType.LIMITED, LocalDateTime.now(), LocalDateTime.now().plusDays(5));
        Agenda agenda = agendaService.createAgenda(agendaDto);

        for (int i = 0; i < 5; i++) {
            UserJoinRequestDto userDto = new UserJoinRequestDto("test" + i, "1234", "name" + i, Role.USER, 10);
            userService.create(userDto);
        }

        // when
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        CountDownLatch countDownLatch = new CountDownLatch(5);

        CountDownLatchT tt = new CountDownLatchT();
        for (int i = 1; i <= 5; i++) {
            executorService.execute(() -> {
                try {
                    tt.vote(agendaService, "test", agenda.getID(), VoteType.POSITIVE, 3);
                } catch (Exception e) {
                    assertThat(e).isInstanceOf(InvalidVoteException.class);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        // then
        countDownLatch.await();
        assertThat(agendaService.getAgenda(agenda.getID()).getTotalRights()).isEqualTo(10);
}

public static class CountDownLatchT {

        int count = 0;

        public void vote(AgendaService agendaService, String userId, Long agendaId, VoteType type, int quantity) {
            agendaService.vote(userId + count++, agendaId, type, quantity);
        }
}

μ„ μ°©μˆœ νˆ¬ν‘œμ˜ λ™μ‹œμ„± ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ μžλ°”μ—μ„œ μ œκ³΅ν•˜λŠ” executorServiceλ₯Ό ν™œμš©ν•˜μ—¬ 병렬 μˆ˜ν–‰ ν…ŒμŠ€νŠΈλ₯Ό κ΅¬ν˜„ν•˜μ˜€κ³ ,

λ™μ‹œμ˜ μš”μ²­μ—λ„ 결과적으둜 10개의 ν‘œλ§Œ 반영이 되고 μ΄ν›„μ˜ μš”μ²­μ€ νˆ¬ν‘œ μ’…λ£Œλ‘œ μΈν•œ μ˜ˆμ™Έλ₯Ό λ˜μ§€λŠ” 뢀뢄도 ν™•μΈν•˜μ˜€μŒ



13. μš”κ΅¬ 사항 9λ²ˆμ— ν•΄λ‹Ήν•˜λŠ” νˆ¬ν‘œ μ‹œμŠ€ν…œμ€ ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό 톡해 검증할 수 μžˆμ–΄μ•Ό ν•œλ‹€

@DisplayName("νˆ¬ν‘œλ₯Ό μˆ˜ν–‰ν•˜λ©΄ νˆ¬ν‘œ μ’…λ₯˜μ— 따라 μ•ˆκ±΄μ— ν‘œκ°€ λˆ„μ λœλ‹€.")
@Test
void voteNormalAgenda() {
        // given
        Agenda agenda = agendaRepository.save(Agenda.create("사내 νœ΄μ‹μ‹œμ„€ 증진", AgendaType.NORMAL,
                LocalDateTime.now().minusDays(1),
                LocalDateTime.now().plusDays(5)));

        User user = userRepository.save(User.builder()
                .userId("test")
                .password("1234")
                .name("홍길동")
                .role(Role.USER)
                .voteRights(50)
                .build()
        );
        VoteType type = VoteType.POSITIVE;
        int quantity = 15;

        // when
        Agenda agendaResult = agendaService.vote(user.getUserId(), agenda.getID(), type, quantity);

        // then
        assertThat(agendaResult.getPositiveRights()).isEqualTo(15);
}

ν•΄λ‹Ή ν…ŒμŠ€νŠΈλ₯Ό 톡해 일반 νˆ¬ν‘œλŠ” μ‚¬μš©μžκ°€ 가진 의결ꢌ λ‚΄μ—μ„œ μ˜κ²°κΆŒμ„ 행사할 수 있고,
μ΄λŠ” μ•ˆκ±΄μ˜ νˆ¬ν‘œ ν˜„ν™©μ— λˆ„μ λ˜λŠ” 것을 ν™•μΈν•˜μ˜€μŒ

@DisplayName("μ‚¬μš©μžκ°€ 가진 νˆ¬ν‘œμˆ˜ μ΄ν•˜μ˜ νˆ¬ν‘œκΆŒλ§Œ 행사할 수 μžˆλ‹€.")
@Test
void overVote() {
        // given
        Agenda agenda = agendaRepository.save(Agenda.create("사내 νœ΄μ‹μ‹œμ„€ 증진", AgendaType.NORMAL,
                LocalDateTime.now(),
                LocalDateTime.now().plusDays(5)));

        User user = userRepository.save(User.builder()
                .userId("test")
                .password("1234")
                .name("홍길동")
                .role(Role.USER)
                .voteRights(10)
                .build()
        );
        VoteType type = VoteType.POSITIVE;
        int quantity = 11;

        // when&then
        assertThatThrownBy(() -> {
            agendaService.vote(user.getUserId(), agenda.getID(), type, quantity);
        }).isInstanceOf(InvalidVoteException.class);
}

λ˜ν•œ ν˜„μž¬ 가진 의결ꢌ μ΄μƒμœΌλ‘œ 행사할 경우, 의결ꢌ 초과 μ˜ˆμ™Έκ°€ λ°œμƒν•˜λŠ” 과정도 ν™•μΈν•˜μ˜€μŒ



μ΅œμ ν™”



spring:
  jpa:
    open-in-view: false

OSIV μ˜΅μ…˜μ„ λ”μœΌλ‘œμ¨ DB connection을 효율적으둜 관리할 수 μžˆλ„λ‘ ν•˜μ˜€μŒ


@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(indexes = {
    @Index(columnList = "agenda_id"),
    @Index(columnList = "agenda_id, user_id")
})
public class Vote extends CreatedAtBaseEntity {
    ...
}

νˆ¬ν‘œ ν…Œμ΄λΈ”μ— λŒ€ν•΄ 볡합 μΈλ±μŠ€μ™€ 단일 인덱슀λ₯Ό 톡해 쑰회 μ„±λŠ₯을 ν–₯μƒμ‹œμΌ°μŒ
100000개의 νˆ¬ν‘œ + 5개 μ•ˆκ±΄μ— λŒ€ν•œ 평균 쑰회 μ‹œκ°„ (154ms -> 41ms) 확인



μ˜ˆμ™Έ 처리



1. μƒνƒœμ½”λ“œμ™€ λ©”μ‹œμ§€λ₯Ό ν¬ν•¨ν•œ μ—λŸ¬μ½”λ“œ

μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ 경우 전달할 μ½”λ“œμ™€ λ©”μ‹œμ§€λ₯Ό Enum type 으둜 μ •μ˜

μš°μ„  μ—λŸ¬ μ½”λ“œ μΈν„°νŽ˜μ΄μŠ€λ₯Ό μ •μ˜ν•˜κ³ ,
보편적인 μ˜ˆμ™Έμ— λ”°λ₯Έ μ½”λ“œμΈμ§€, μ‚¬μš©μžκ°€ μ •μ˜ν•œ μ˜ˆμ™Έμ— λ”°λ₯Έ μ½”λ“œμΈμ§€λ₯Ό κ΅¬λ³„ν•΄μ„œ κ΅¬ν˜„ν•˜μ˜€μŒ

public interface ErrorCode {

    HttpStatus getHttpStatus();
    String getMessage();
}
@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode {

    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
    NO_ELEMENT(HttpStatus.NOT_FOUND, "Element not exists"),
    ;

    private final HttpStatus httpStatus;
    private final String message;
}
@Getter
@RequiredArgsConstructor
public enum UserErrorCode implements ErrorCode {

    EMPTY_CLAIM(HttpStatus.UNAUTHORIZED, "No authentication information in claim"),
    DUPLICATED_ID(HttpStatus.CONFLICT, "ID already exists"),
    WRONG_PERIOD(HttpStatus.BAD_REQUEST, "Not voting period"),
    EXCEED_VOTE(HttpStatus.BAD_REQUEST, "Exceeding the number of possible votes"),
    DUPLICATED_VOTE(HttpStatus.BAD_REQUEST, "Duplicate voting is not allowed")
    ;

    private final HttpStatus httpStatus;
    private final String message;
}

곡톡적인 μ˜ˆμ™Έλ‘œλŠ”

  • μŠ€ν”„λ§ μœ νš¨μ„± 검증 λ‹¨κ³„μ—μ„œ (Spring validation) λ°œμƒμ‹œν‚€λŠ” μ˜ˆμ™Έ
  • 잘λͺ»λœ ID λ₯Ό ν†΅ν•œ μžμ› μ‘°νšŒμ— λŒ€ν•΄ Optional μ—μ„œ 기본적으둜 λ°œμƒμ‹œν‚€λŠ” μ˜ˆμ™Έ

두 가지 μ˜ˆμ™Έλ₯Ό 닀루도둝 섀계 ν•˜μ˜€κ³ ,

μ‚¬μš©μž μ •μ˜ μ˜ˆμ™Έλ‘œλŠ”

  • 토큰에 ν΄λ ˆμž„ 정보가 없을 λ•Œ μ˜ˆμ™Έ
  • μ€‘λ³΅λœ ID 둜 νšŒμ› κ°€μž…μ„ μš”μ²­ν•  λ•Œ μ˜ˆμ™Έ
  • μ˜¬λ°”λ₯΄μ§€ λͺ»ν•œ νˆ¬ν‘œ μš”μ²­μ„ λ³΄λƒˆμ„ λ•Œ μ˜ˆμ™Έ

μ„Έ 가지 μ˜ˆμ™Έλ₯Ό 닀루도둝 μ„€κ³„ν•˜μ˜€μŒ


2. μ˜ˆμ™Έ μ •μ˜

@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {

    private final ErrorCode errorCode;
}
public class InvalidVoteException extends RestApiException {

    public InvalidVoteException(ErrorCode errorCode) {
        super(errorCode);
    }
}
public class DuplicateUserException extends RestApiException {

    public DuplicateUserException(ErrorCode errorCode) {
        super(errorCode);
    }
}

μš°μ„  μ˜ˆμ™Έ λ‚΄ νŒŒλΌλ―Έν„°λ‘œ 넣을 μ—λŸ¬μ½”λ“œλ₯Ό RestApiException 으둜 μ •μ˜ν•˜κ³ ,
ꡬ체적인 μ˜ˆμ™Έλ₯Ό ν•΄λ‹Ή μ˜ˆμ™Έ 상속 λ°›λŠ” ꡬ쑰둜 μ •μ˜ν•˜μ˜€μŒ

μœ„μ—μ„œ μ •μ˜ν•œ μ—λŸ¬μ½”λ“œλ₯Ό νŒŒλ¦¬λ―Έν„°λ‘œ μ „λ‹¬ν•˜λ©΄, μ˜ˆμ™Έ 객체 λ‚΄ μ—λŸ¬ μ½”λ“œλ₯Ό κ°€μ§€κ²Œ 되고,
ν•΄λ‹Ή μ˜ˆμ™Έλ₯Ό μ²˜λ¦¬ν•  λ•Œ λ©”μ‹œμ§€μ™€ μƒνƒœμ½”λ“œλ₯Ό 확인 κ°€λŠ₯


3. κΈ€λ‘œλ²Œ μ˜ˆμ™Έ 처리 ν•Έλ“€λŸ¬

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(RestApiException.class)
    public ResponseEntity<Object> handleCustomException(RestApiException e) {
        ErrorCode errorCode = e.getErrorCode();
        return new ResponseEntity<>(errorCode.getMessage(), errorCode.getHttpStatus());
    }

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<Object> handleEntityNotFound(EntityNotFoundException e) {
        ErrorCode errorCode = CommonErrorCode.RESOURCE_NOT_FOUND;
        return new ResponseEntity<>(errorCode.getMessage(), errorCode.getHttpStatus());
    }

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<Object> handleNoSuchElement(NoSuchElementException e) {
        ErrorCode errorCode = CommonErrorCode.NO_ELEMENT;
        return new ResponseEntity<>(errorCode.getMessage(), errorCode.getHttpStatus());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException e) {
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        return new ResponseEntity<>(errorCode.getMessage(), errorCode.getHttpStatus());
    }
}

μ΅œμ’…μ μœΌλ‘œ μ„œλ²„μ—μ„œ λ°œμƒν•˜λŠ” μ˜ˆμ™Έλ₯Ό λ°›μ•„μ„œ μ²˜λ¦¬ν•˜λŠ” ν•Έλ“€λŸ¬ 클래슀

μƒλ‹¨μ—μ„œ μ •μ˜ ν–ˆλ˜ RestApiException κ³Ό 일반적으둜 λ°œμƒν•  수 μžˆλŠ” μ˜ˆμ™Έμ— λŒ€ν•΄ 처리 과정을 μž‘μ„±ν•˜λ©΄,
μ •μ˜ ν–ˆλ˜ μ—λŸ¬ μ½”λ“œλ₯Ό 톡해 μ‚¬μš©μžμ—κ²Œ 응닡을 전달할 수 있게 됨