Player(선수) - Team(팀) : 다대일 관계
하나의 팀은 여러 명의 선수를 가집니다.
Player.java
public class Player {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
//== 연관관계 편의 메서드 ==//
//== 비즈니스 로직 ==//
/**
* 팀 등록
*/
public void registerTeam(Team team) {
if (this.team != null) {
this.team.getPlayers().remove(this);
}
this.team = team;
if (team != null) {
team.getPlayers().add(this);
}
}
/**
* 팀 해제
*/
public void deregisterTeam() {
this.team.getPlayers().remove(this);
this.team = null;
}
}
- Player는 Team과 다대일 관계로, @ManyToOne 어노테이션을 지정합니다. @JoinColumn 어노테이션을 통해 FK를 매핑합니다.
- 패치방식은 지연로딩으로 설정합니다.
- registerTeam과 deregisterTeam은 연관관계 편의 메서드로 Team과 Player의 양방향 연관관계에서 나올 수 있는 데이터 불일치 문제를 방지합니다.
Team.java
@Entity
@Getter
public class Team {
@OneToMany(mappedBy = "team", fetch = LAZY)
private List<Player> players = new ArrayList<>();
}
- Team은 Player와 일대다 관계로, @OneToMany 어노테이션을 지정합니다.
- Team에서는 선수를 추가할 수 없습니다. 그러므로 연관관계 편의 메서드가 필요없습니다.
Team(팀) - Participant - League(대회) : 일대다-다대일 관계
Leaugue(대회)와 Team(팀)은 다대다 관계입니다. 대회는 여러 팀을 가지고 있고, 팀은 여러 대회를 가질 수 있습니다. 다대다 관계를 일대다-다대일 관계로 풀어낼 매핑 엔티티가 필요합니다. 동시에 팀이 리그에서 기록한 전적을 담을 엔티티도 필요합니다.
이 두가지 역할을 동시에 수행하는 엔티티가 Participant 엔티티 입니다.
Participant 엔티티는 Team(팀)과 League(대회)를 매핑함과 동시에 팀의 리그 전적 데이터를 관리합니다.
Participant.java
@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<>();
//== 생성 메서드 ==//
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;
}
}
- Participant는 Team, League와 각각 다대일 관계를 맺고 있어, 어떤 팀이 어떤 대회에 참가하는지에 대한 정보를 기록합니다.
- Record는 한 팀이 한 리그에서의 전적 데이터입니다.
- Participant 엔티티를 생성하는 것이 팀이 리그에 참가하는 것입니다. 그러므로 Participant 엔티티 생성시 생성된 엔티티를 Team, ㅣLeauge 엔티티에 추가해줘야 연관관계가 유지됩니다.
League.java
@Entity
@Getter @Setter
public class League {
@OneToMany(mappedBy = "league", cascade = CascadeType.ALL)
private List<Participant> participants = new ArrayList<>();
}
- Cascade 타입을 ALL로 설정하여, League가 삭제 시 해당 League를 FK로 갖는 모든 Participant가 삭제됩니다.
Team.java
@Entity
@Getter
public class Team {
@OneToMany(mappedBy = "team", fetch = LAZY)
private List<Participant> participants = new ArrayList<>();
}
사실 처음 개발시에는 모든 일대다 관계를 가진 엔티티에 CASCADE 옵션을 넣어줬다가 큰 낭패를 봤다. 해당 문제에 대한 본인의 고찰이 담긴 글은 아래에 있다...
Cascade에 대하여...
나는 처음 Spring JPA를 공부하다 다음과 같은 말들 들어본적이 있다. cascade 속성을 설정해놓으면 오류가 발생하지 않는다. 나도 위 말은 신봉(?)했고, 아무 생각없이 모든 엔티티의 cascade 속성을 ALL
ohreallystore.tistory.com
Participant - Match : 일다대 관계
도메일 모델을 설계하면서 가장 큰 고민이었던 것이 Match(경기) 엔티티의 연관관계이다. 단순하게 경기는 팀이 하니 팀과 일대다 관계가 아니냐? 라고 할 수 있겠지만 이러면 경기가 어떤 리그의 경기였는지 알 수 없다. 즉, 경기는 리그와 팀 정보를 모두 담고 있어야 한다.
경기 정보가 입력되면 또 어떠한 데이터가 변경될까? 경기 결과에 따른 경기에 참여 팀들의 대회전적 데이터가 수정 될 것이다. 즉, 전적 데이터를 가진 엔티티와 연관관계를 맺는 것이 가장 합리적이다.
결국 Participant 엔티티와 Match가 일대다 관계를 맺는 것이 가장 좋을 것으로 판단되었다.
Participant와 Match 사이에 일어나는 핵심 비지니스 로직은 크게 2가지이다.
- 경기 생성
- 경기 결과 입력 및 전적 업데이트
- 전적 업데이트에 따른 순위(ranking) 업데이트
일단 경기 생성 및 경기 결과 입력은 모두 Match 엔티티에서 일어나는 것이 타당하다. 왜냐하면 해당 로직에 필요한 데이터를 모두 Match가 가지고 있기 때문이다. 그렇다면 순위 업데이트는 어떨까? 일단 순위 업데이트를 하려면 리그에 참가하는 모든 Participant를 필요로 한다. 이 정보를 가지고 있는 엔티티는 League이다. 즉, 순위 업데이트에 대한 책임은 League엔티티가 가지는게 타당하다.
참고. 순위 업데이트는 전적 기록만 있으면 되므로 League가 Match와 직접적인 연관관계를 맺지 않아도 상관없습니다.
- 경기 생성 - Match
- 경기 결과 입력 / 전적업데이트 - Math
- 순위 업데이트 - League
자 이제 재밌는 일이 생기는데, 사실상 Participant는 Match와 연관관계를 맺지만, 실제 접근할 일은 없다. 즉, League와 Match 사이에는 단방향 연관관계를 맺어도 무관한것이다.
JPA와 연관관계 편의 메서드가 양방향 참조관계를 보다 안전하게 유지해주긴 하지만, 양방향 의존 관계를 언제나 위험하다. 필요없다면 안 쓰는 것이 당연히 좋다.
Match.java
@Entity
@Getter
public class Match implements Comparable<Match> {
@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;
}
}
- Match는 2개의 Participant와 각각 다대일 관계를 맺는다. @JoinColumn에 경우 FK 이름을 다르게 지정해주면 된다.
- 경기 결과를 입력하는 기능은 Match가 Partipant 엔티티를 참조하여 진행한다.
- Particpant는 Match를 참조하지 않으므로 Match에 관한 코드가 없다.
Team.java
@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).setRank(currRank);
numTie++;
} else {
currRank += numTie;
records.get(i).setRank(currRank);
numTie = 1;
}
}
}
}
- League는 자기 대회에 참가한 Participant들을 참조하므로 해당 데이터를 이용해 Participant의 순위(rank)를 업데이트 한다.
- updateRank() 메서드는 Participant 순위를 지정하는 메서드이다.
핵심 비지니스 로직을 어디에 둘것인가?
영속성 컨텍스트인 JPA는 변경감지(dirty checking) 기능을 통해 직접적인 수정 쿼리 없이 엔티티의 필드값을 수정하는 것 만으로도 DB의 데이터를 수정할 수 있다.
자세한 내용은 여기
[JPA] 영속성 컨텍스트 (Persistance Context)
JPA를 사용함에 있어 가장 중요한 개념은 아마 영속성 컨텍스트(Persistance Context)일 것이다. em.persist(member); JPA는 다음과 같인 엔티티 매니저(Entity Manager)의 메서드인 persist() 메서드를 통해 객체를
ohreallystore.tistory.com
기존의 MVC 패턴에서 비지니스 로직을 처리하는 과정을 살펴보자
대부분의 핵심 비지니스 로직은 Service 코드에서 수행된다. 이때 Service는 데이터베이스의 데이터를 처리해야하므로 DB에 쿼리를 전송하는 역할을 Respository에 위임한다.
그런데 JPA에서는 데이터 수정의 경우 쿼리 없이 도메인의 필드 수정만으로 이루어 질 수 있다. 즉, 비지니스 로직을 도메인에서 처리할 수 있게 되는 것이다. 이를 통해 많은 양의 비지니스 로직의 수행을 도메인에게 위임할 수 있다.
MatchService.java
@Transactional
public Match playMatch(Long matchId, int homeScore, int awayScore) {
Match match = matchRepository.findOne(matchId);
match.matchTeams(homeScore, awayScore);
return match;
}
위는 MatchService에서 경기 결과를 입력하는 메서드이다. 경기결과를 입력하는 역할은 Match에게 위임한다. 이런식으로 세부 비지니스 로직을 도메인에게 위임하고 서비스는 해당 로직을 호출하는 식으로 코드를 수정할 있는데 이러한 개발방식을 도메인 주도 개발이라고 한다.
도메인 주도 개발(Domain Driven Development)은 비지니스 로직의 세부 사항을 도메인에게 위임하고 캡슐링함으로써 서비스 코드를 간소화하고, 핵심 로직을 도메인이 직접 관리해 직관성을 높인다.
'프로젝트 이야기 > MyLeague API' 카테고리의 다른 글
[MyLeague] Spring HATEOAS 적용한 향상된 REST API 개발 (0) | 2023.02.06 |
---|---|
[MyLeague:bug] 자식 컴포넌트 렌더링시 부모의 일부데이터가 props로 넘어오지 않는 문제 (0) | 2023.01.25 |
[MyLeague] 대회 방식에 따른 대응을 위한 데이터베이스 구조 개선 (1) | 2023.01.11 |