본문 바로가기

프로젝트 이야기/MyLeague API

[MyLeague] 대회 방식에 따른 대응을 위한 데이터베이스 구조 개선

간단한 프로젝트 설명 : MyLeague

 

다양한 스포츠 리그를 생성하고 관리할 수 있는 웹 서비스.

사용자는 다양한 종목의 스포츠 리그/대회를 생성할 수 있고, 팀, 선수, 대회를 관리할 수 있다.

 

기존의 DB 구조

기본적으로 6개의 엔티티가 디비를 구성하고 있다.

선수(Player), 팀(Team), 대회/리그(League), 참가팀(Participant), 경기(Match), 전적(Record)이다. 이때 참가팀 엔티티는 대회와 팀을 연결하는 연결 엔티티 역할을 함과 동시에 Record엔티티를 통해 각 리그별 팀의 전적을 저장하고 있다. 선수, 팀, 대회, 경기는 이름 그대로 각 주체의 정보를 담고 있다.

 

서비스의 확장과 디비의 문제점

 

현재 위의 데이버베이스 구조에서는 전적(승무패)를 기준으로 순위를 결정하는 하나의 리그를 운영하는데는 아무런 문제가 없다. 대략적으로 리그가 운영되는 핵심적인 비지니스 로직은 아래와 같다.

 

1.  팀은 참가팀(Participant) 엔티티를 생성함으로써 대회(League)에 참가한다.

2. 대회는 참가팀을 기반으로 경기를 생성할 수 있다.

3. 경기(Match) 결과가 입력되면 해당 경기 결과가 참가팀 레코드(Record)에 반영이 되고 대회는 참가팀의 순위를 수정 한다.

 

이렇게 서비스가 운영되면 대회는 사실상 전적에 따른 순위 업데이트 밖에 할 수 없다. 즉, 토너먼트나 조별리그 같은 다양한 방식의 대회의 대응이 불가능하다. 또한 하나의 리그 내에 여러가지 방식의 라운드(Round)가 존재할 경우, 이를 대응하기도 어렵다. 예를 들어 예선은 조별리그 방식이고, 본선은 풀리그, 결선은 녹아웃(Knockout) 방식이라 해보자. 그러면 위의 방식으론 각 방식에 따른 점수및 순위 산정이 어렵다.

 

즉, 서비스를 더 유연하게 확장하기 위해서는 데이버베이스 구조 수정이 불가피하다.

 

데어베이스 구조 개선

 

Record 도메인 수정

기존의 Record는 아래와 같이 참가자(Participant)와 1대1 연관관계를 지정하고 있다. 또한 필드값을 통해 전적을 관리하며 addMatchResult, removeMatchResult 메서드를 통해 전적을 수정한다.

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter @Setter
public class Record {

    @Id @GeneratedValue
    @Column(name = "RECORD_ID")
    private Long id;

    @OneToOne(mappedBy = "record")
    private Participant participant;

    private int win = 0;
    private int draw = 0;
    private int loss = 0;
    private int setWin = 0;
    private int setLoss = 0;
    private int score = 0;
    private int rank = 1;


    //== 비즈니스 메서드 ==//

    /**
     * 매치 결과를 전적에 업데이트
     */
    public void addMatchResult(int matchSetWin, int matchSetLoss) {
        this.setWin += matchSetWin;
        this.setLoss += matchSetLoss;

        if (matchSetWin > matchSetLoss) {
            this.win++;
        } else if (matchSetWin < matchSetLoss) {
            this.loss++;
        } else {
            this.draw++;
        }
    }

    /**
     * 경기 취소로 전적 초기화
     */
    public void removeMatchResult(int matchSetWin, int matchSetLoss) {
        this.setWin -= matchSetWin;
        this.setLoss -= matchSetLoss;

        if (matchSetWin > matchSetLoss) {
            this.win--;
        } else if (matchSetWin < matchSetLoss) {
            this.loss--;
        } else {
            this.draw--;
        }
    }
    
    /**
     * TODO 점수 계산
     */
    public void updateScore() {
        this.score = (win - loss) * 100 + setWin - setLoss;
    }
}

 

하지만 하나의 리그안에는 여러개의 라운드가 존재할 수 있다. 그렇다면 각각의 라운드의 전적을 분리하여 저장할 필요가 있다. 그러므로 기존의 1대1 연관관계였던 Participant와 Record 엔티티의 연관관계를 1대다 관계로 수정하였다. 또한 Record가 다양한 방식의 라운드에 대응할 수 있도록 추상클래스로 구현하고 각 방식에 대응하는 상속 엔티티를 구현하였다.

 

import jangseop.myleague.domain.Match;
import jangseop.myleague.domain.Participant;
import jangseop.myleague.domain.Playoff;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

import java.util.ArrayList;
import java.util.List;

import static jangseop.myleague.domain.Playoff.*;
import static javax.persistence.CascadeType.ALL;
import static javax.persistence.FetchType.*;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Record {

    @Id @GeneratedValue
    @Column(name = "RECORD_ID")
    private Long id;

    protected int round;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "PARTICIPANT_ID")
    protected Participant participant;

    @OneToMany(mappedBy = "home", fetch = LAZY, cascade = ALL)
    private List<Match> homeMatches = new ArrayList<>();

    @OneToMany(mappedBy = "away", fetch = LAZY, cascade = ALL)
    private List<Match> awayMatches = new ArrayList<>();

    protected int win = 0;
    protected int draw = 0;
    protected int loss = 0;
    protected int setWin = 0;
    protected int setLoss = 0;
    protected int score = 0;
    protected int rank = 1;

    protected int promotion = 0;


    /**
     * set rank
     */
    public void setRank(int rank) {
        this.rank = rank;
    }

    /**
     * 생성 메서드
     */
    public static Record createRecord(int round, Playoff type) {
        Record record = null;
        if (type == FULL_LEAGUE) record = new FullLeague();
        else if (type == KNOCKOUT) record = new Knockout();

        // null checking
        if (record == null) return null;

        record.round = round;
        return record;
    }
    
    //== 비즈니스 메서드 ==//

    /**
     * 경기 취소로 전적 초기화
     */
    abstract public void removeMatchResult(int matchSetWin, int matchSetLoss);

    /**
     * 매치 결과를 전적에 업데이트
     */
    abstract public void addMatchResult(int matchSetWin, int matchSetLoss);

    /**
     * TODO 점수 계산
     */
    abstract public void updateScore();
}

기존의 전적을 그대로 관리한다. 하지만 Match 결과의 업데이트에 따른 전적 수정 및 점수 업데이트는 진행 방식에 따라 달리지므로 추상 메서드로 정의하고 구현은 상속 클래스가 직접한다. 

또한 다대일 관계이므로 Record를 상황에 따라 여러개 생성할 수 있으므로 편의성을 위해 생성 메서드를 구현하였다. 생성메서드는 enum 타입으로 넘어오는 경기 방식에 맞게 상속 엔티티를 생성한다.

 

Participant 도메인 수정

Record 엔티티의 변경으로 인해 그와 연관관계를 맺고 있는 Participant 엔티티 역시 변경이 불가피하다. 일단 Record 엔티티와의 관계를 일대다 관계로 수정해야 한다. 또한 경기(Match) 엔티티와의 연관관계를 Record에게 넘겨줬으므로 해당 관계도 끊어야 한다. 또한 여러개의 Record를 가질 수 있으므로 Record를 추가하는 메서드를 구현하였다.

@Entity
@Getter @Setter
public class Participant {

    @Id
    @GeneratedValue
    @Column(name = "PARTICIPANT_ID")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "LEAGUE_ID")
    private League league;


    @OneToMany(mappedBy = "participant", fetch = LAZY, cascade = ALL)
    private List<Record> records = new ArrayList<>();

    private int totalRank;

    //== 생성 메서드 ==//

    public static  Participant createParticipant(Team team, League league) {
        Participant participant = new Participant();

        participant.team = team;
        team.getParticipants().add(participant);

        participant.league = league;
        league.getParticipants().add(participant);


        return participant;
    }

    public void updateRanking(int round) {
        this.league.updateRecordRank(round);
        this.league.updateRanking();
    }

    //== 비즈니스 로직 ==//
    public void addRecord(Record record) {
        if (record.getParticipant() != null) {
            record.getParticipant().removeRecord(record);
        }

        this.records.add(record);
        record.setParticipant(this);
    }

    public void removeRecord(Record record) {
        this.records.remove(record);
        record.setParticipant(null);
    }


}

코드는 기존 코드와 거의 동일하나 연관관계 해제에 따라 Match가 필드에서 제거했다. Record 엔티티와의 연관관계를 일대다 관계로 수정하였고  Record를 추가, 삭제하는 연관관계 편의 메서드를 구현하였다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ParticipantService {

    private final ParticipantRepository participantRepository;
    private final TeamRepository teamRepository;
    private final LeagueRepository leagueRepository;
    private final RecordRepository recordRepository;

    /**
     * 새로운 라운드(기록) 추가
     */
    @Transactional
    public Record addRecord(Long id, int round, Playoff type) {
        Record record = Record.createRecord(round, type);
        recordRepository.save(record);

        Participant findParticipant = participantRepository.findOne(id);
        findParticipant.addRecord(record);

        return record;
    }

}

추가적으로 새로운 기록(Record)를 추가하는 메서드를 서비스 파트에 추가하였다.

 

Match 도메인/서비스 수정 및 순위 갱신 방식 변경

경기(Match) 엔티티도 기존의 Participant와의 다대일 관계가 해제되고, Record와 새로운 다대일 관계가 형성되었으므로 이에 따른 도메인 코드 수정이 필요하다.

 

@Entity
@Getter
public class Match implements Comparable<Match> {

    @Id
    @GeneratedValue
    @Column(name = "MATCH_ID")
    private Long id;

    private int round;
    private Date matchDate;

    @ManyToOne
    @JoinColumn(name = "HOME_RECORD_ID")
    private Record home;

    @ManyToOne
    @JoinColumn(name = "AWAY_RECORD_ID")
    private Record away;

    private int homeScore = -1;
    private int awayScore = -1;

    //== 생성 메서드 ==//

    /**
     * create Match
     */
    public static Match createMatch(Date date, int round, Record home, Record away) {
        Match match = new Match();

        match.matchDate = date;
        match.round = round;

        match.home = home;
        match.away = away;
        home.getHomeMatches().add(match);
        away.getAwayMatches().add(match);

        return match;
    }

    //== 비지니스 로직 ==//

    /**
     * match
     */
    public void matchTeams(int homeScore, int awayScore) {

        if (this.homeScore != -1 && this.awayScore != -1) {
            this.home.removeMatchResult(this.homeScore, this.awayScore);
            this.away.removeMatchResult(this.awayScore, this.homeScore);
        }

        this.homeScore = homeScore;
        this.awayScore = awayScore;

        this.home.addMatchResult(homeScore, awayScore);
        this.away.addMatchResult(awayScore, homeScore);
    }

    /**
     * cancel match
     */
    public void cancelMatchTeams() {
        if (this.homeScore != -1 && this.awayScore != -1) {
            this.home.removeMatchResult(this.homeScore, this.awayScore);
            this.away.removeMatchResult(this.awayScore, this.homeScore);
        }

        this.homeScore = -1;
        this.awayScore = -1;
    }

    @Override
    public int compareTo(Match m) {

        // null handling
        if (m.getMatchDate() == null) return 1;
        if (this.matchDate == null) return -1;

        if (m.getMatchDate().getTime() < this.matchDate.getTime()) return 1;
        else if (m.getMatchDate().getTime() > this.matchDate.getTime()) return -1;
        else return 0;
    }
}

기본적으로 경기에 참여하는 엔티티가 Participant에서 Record로 변경되었다. 그러므로 경기 결과 수정에 따른 전적 수정의 경우도 Record의 메서드를 직접 호출한다.

 

경기의 결과과 입력(또는 수정)되면 단순히 전적만 수정되는게 아니라 수정된 전적에 따라 순위도 수정되어야 한다. 기존의 코드의 경우 Match가 Participant를 참조하였으므로 Participant가 Record의 전적 수정 메서드(addMatchResult/removeMatchResult)를 호출해 전적을 수정하고 순위 수정 메서드를 호출해 순위를 수정하는 방식으로 비즈니스 로직이 흘러 갔다.

@Entity
@Getter @Setter
public class Participant implements Comparable<Participant> {

	/* 생략... */

    //== 비즈니스 로직 ==//

    /**
     * add match result
     */
    public void addMatchResult(int myScore, int otherScore) {
        record.addMatchResult(myScore, otherScore);
        record.updateScore();
        league.updateRanking();
    }

    /**
     * remove match result
     */
    public void removeMatchResult(int myScore, int otherScore) {
        record.removeMatchResult(myScore, otherScore);
        record.updateScore();
        league.updateRanking();
    }
}

위의 코드에서 addMatchResult, removeMatchResult 메서드를 보면 Record의 메서드를 호출해 전적과 점수를 수정하고 리그의 순위산정메서드를 호출해 순위를 수정하는 것을 볼 수 있다.

 

하지만 수정된 Match 엔티티는 Participant를 참조하지 않는다. 즉, 새롭게 참조한 Record 객체에서 전적, 점수 수정 및 순위 수정까지 해야한다. 

수정된 코드를 보면 아래와 같다.

@Entity
@DiscriminatorValue("L")
@Getter
public class FullLeague extends Record {

    private int winPt = 100;
    private int drawPt = 0;
    private int lossPt = -1;
    private int roundRobin;

    @Override
    public void addMatchResult(int matchSetWin, int matchSetLoss) {
        this.setWin += matchSetWin;
        this.setLoss += matchSetLoss;

        if (matchSetWin > matchSetLoss) {
            this.win++;
        } else if (matchSetWin < matchSetLoss) {
            this.loss++;
        } else {
            this.draw++;
        }
        this.updateScore();
        this.participant.updateRanking(this.round);
    }

    @Override
    public void removeMatchResult(int matchSetWin, int matchSetLoss)  {
        this.setWin -= matchSetWin;
        this.setLoss -= matchSetLoss;

        if (matchSetWin > matchSetLoss) {
            this.win--;
        } else if (matchSetWin < matchSetLoss) {
            this.loss--;
        } else {
            this.draw--;
        }

        this.updateScore();
        this.participant.updateRanking(this.round);
    }
}

위 코드는 Record를 상속한 엔티티인 FullLeague이다. FullLeague 엔티티는 승/무/패로 전적을 관리하면, 전적을 점수로 산정 후 순위를 업데이트 한다. addMatchResult와 removeMatchResult 메서드를 보면 전적및 점수를 수정한 후, participant의 updateRanking 메서드를 통해 순위를 갱신한다.

 

순위 갱신은 어느 엔티티가 해야할까?

기본적으로 순위 갱신은 경기결과가 입력(또는 수정)되면 2개의 단계로 나눠서 진행된다. 한 라운드에 대한 순위의 갱신, 한 대회에 대한 종합순위 갱신이다. 라운드에 대한 순위 갱신은 Record 엔티티를 정렬하는 과정이고, 종합순위 갱신은 Participant 엔티티를 정렬하는 과정이다. 즉, 순위 갱신은 Record 엔티티와 Participant 엔티티를 참조할 수 있는 엔티티가 순위 갱신 역할을 맡는게 타당하다. 현재의 데이터베이스 구조에서는 League 엔티티가 순위 갱신을 맡는것이 타당해 보인다.

 

@Entity
@Getter @Setter
public class League {

    ... 중략 ...
    
    @OneToMany(mappedBy = "league", cascade = CascadeType.ALL)
    private List<Participant> participants = new ArrayList<>();
	
    ... 중략 ...

    //== 비즈니스 로직 ==//

    /**
     * update rank
     */
    public void updateRanking() {
        Comparator<Participant> comparator = new Comparator<>() {
            @Override
            public int compare(Participant p1, Participant p2) {
                p1.getRecords().sort(Comparator.comparing(Record::getRound).reversed());
                p2.getRecords().sort(Comparator.comparing(Record::getRound).reversed());

                if (p1.getRecords().get(0).getRound() > p2.getRecords().get(0).getRound()) return -1;
                else if (p1.getRecords().get(0).getRound() < p2.getRecords().get(0).getRound()) return 1;
                else {
                    int idx = 0;
                    while (idx < p1.getRecords().size()) {
                        if (p1.getRecords().get(idx).getRank() < p2.getRecords().get(idx).getRank()) return -1;
                        else if (p1.getRecords().get(idx).getRank() > p2.getRecords().get(idx).getRank()) return 1;
                        idx++;
                    }
                    return 0;
                }
            }
        };

        Collections.sort(this.participants, comparator);

        int currRank = 1;
        int tieNum = 1;

        this.participants.get(0).setTotalRank(currRank);

        for (int idx=1; idx<this.participants.size(); ++idx) {
            if (comparator.compare(this.participants.get(idx), this.participants.get(idx-1)) == 0) {
                this.participants.get(idx).setTotalRank(currRank);
                tieNum++;
            } else {
                currRank += tieNum; tieNum = 1;
                this.participants.get(idx).setTotalRank(currRank);
            }
        }
    }



    /**
     * update rank of each round
     */
    public void updateRecordRank(int round) {

        List<Record> records = this.participants.stream()
                .map(participant -> (
                    participant.getRecords().stream()
                            .filter(record -> record.getRound() == round)
                            .collect(Collectors.toList()).get(0)
                )).collect(Collectors.toList());

        // sorting by score
        records.sort(Comparator.comparing(Record::getScore).reversed());

        // insert ranking
        int currRank = 1;
        int numTie = 1;

        records.get(0).setRank(currRank);

        for (int i=1; i<records.size(); ++i) {
            if (records.get(i-1).getScore() == records.get(i).getScore()) {
                records.get(i).setScore(currRank);
                numTie++;
            } else {
                currRank += numTie;
                records.get(i).setRank(currRank);
                numTie = 1;
            }
        }
    }
}

순위 갱신을 위해서는 List로 저장되어있는 엔티티들을 정렬해야한다. 이때는 java의 Comparator를 사용하였다. Record 정렬은 Record의 Score를 기준으로 내림차순 정렬하였다. Participant는 Record의 순위를 기반으로 정렬한다.