주요내용
중간기록#2 이후로 DB 연결 및 타이머 연결을 진행
진행사항을 받아서 팀원의 코드를 받아서 연결되는 DB코드 (DB Connector)를 사용해 플레이어들의 정보를 간단히 기록해볼 수 있는 CRUD를 구현하는 DB의 생성을 목표로함.
새로운 기능인 타이머를 도입하기 위해 우선 클라이언트 측에서 UI에 타이머를 띄울 수 있도록 해당 부분의 코드를 작성.
타이머는 캔버스 패널에 붙여서 진행.
...
// 타이머 레이블 추가 (제시어 옆)
timerLabel = new JLabel("⏱ --", JLabel.CENTER);
timerLabel.setFont(new Font("맑은 고딕", Font.BOLD, 16));
timerLabel.setForeground(Color.RED);
timerLabel.setBackground(Color.lightGray);
timerLabel.setOpaque(true);
timerLabel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10));
// 제시어와 타이머를 함께 표시하는 패널
JPanel headerPanel = new JPanel(new BorderLayout());
headerPanel.setBackground(Color.lightGray);
headerPanel.add(keyword, BorderLayout.CENTER);
headerPanel.add(timerLabel, BorderLayout.EAST);
add(colorPalette, BorderLayout.WEST);
add(canvas, BorderLayout.CENTER);
add(headerPanel, BorderLayout.NORTH);
add(eraseButton,BorderLayout.SOUTH);
타이머는 서버에서 받은 시간을 계속해서 업데이트하기에 타이머를 업데이트하는 메소드를 또 하나의 스레드로 구현하는 것을 생각했으나, EDT를 사용하는 것이 더 안전하다고해 해당 방법을 사용.
GUI 요소에 접근할 때, SwingUtilities.invokeLater()를 사용해서 타이머 업데이트 메소드 부분을 단순한 스레드로 만들지 않고, 이 작업(=GUI 컴포넌트 업데이트 코드를) 을 GUI를 전담하는 EDT(=즉, GUI의 업데이트를 전담하는 GUI 내 하나의 스레드, 이 스레드에서 GUI 업데이트 전반을 관리)에게 위임하게함.
GUI 업데이트를 하나의 스레드에 위임하면, 해당 큐에 작업을 예약함. invokeLater() 사용해서 전달된 Runnable 작업은 큐의 맨뒤에 추가됨.
-> 이전에 다중 스레드를 사용하면서 겪은 문제인 경쟁 조건(Race condition) 문제나 오류를 방지할 수 있음. (왜냐하면, 다중 스레드에서는 여러 스레드가 동시에 접근해서 문제가 발생할 수 있는 것인데, 이런 작업들은 하나의 스레드(EDT)에 의해서 순차적으로 처리되게 되기 때문에 경쟁 조건 원인이 제거됨.
결론: 서버 통신 등은 스레드로 처리해도, GUI 업데이트 부분은 하나의 스레드에서 처리하게 하는 것이 안전하기에 사용...
public void updateTimer(int time) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
timerLabel.setText("⏱ " + time + "초");
if (time <= 5) {
timerLabel.setForeground(Color.RED);
} else if (time <= 10) {
timerLabel.setForeground(Color.ORANGE);
} else {
timerLabel.setForeground(Color.BLACK);
}
}
});
}
이후 서버의 TIMER 시간 관련 메시지를 받으면 파싱할 수 있도록 커맨드 인터페이스를 구현하는 클래스를 하나 더 만들고, 이를 커맨드팩토리 클래스의 맵에 추가해줌.
public class TimerCommand implements Command {
@Override
public void execute(ViewController viewController, String data) {
if (data != null) {
try {
int time = Integer.parseInt(data);
viewController.updateTimer(time);
} catch (NumberFormatException e) {
System.out.println("타이머 값 파싱 오류: " + data);
}
}
}
}
...
commandMap.put("TIMER", new TimerCommand());
Map에 한 줄만 추가해주면, 다른 부분은 전혀 손대지 않고 파싱해야하는 명령어 추가할 수 있어서 좋다..
이후에는 서버 측에서의 작업을 위해서 GameRoom이라는 Controller에 타이머를 두고, 타이머의 시작 및 멈추는 메소드를 작성.
Timer라는 객체를 사용했는데, 이는 타이머 작업을 예약하고 관리하는 객체로, 타이머 작업 실행을 위해 내부적으로 하나의 전용 스레드를 사용한다. -> 그래서 이 타이머 부분 구현에서 코드로 직접 스레드를 작성할 필요는 없었다.
public void startTimer() {
// 기존 타이머가 있으면 취소
if (gameTimer != null) {
gameTimer.cancel();
}
remainingTime = 30;
gameTimer = new Timer();
gameTimer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if (remainingTime > 0) {
broadcastToRoom("TIMER:" + remainingTime);
remainingTime--;
} else {
// 시간 종료 시 다음 라운드로
gameTimer.cancel();
broadcastToRoom("[System] 시간이 종료되었습니다!");
gameService.nextRound(GameRoom.this);
}
}
}, 0, 1000); // 0초 후 시작, 1초마다 실행
}
public void stopTimer() {
if (gameTimer != null) {
gameTimer.cancel();
gameTimer = null;
}
DB 연결을 위해서 DBConnector라는 클래스를 만들어서 그 안에 코드를 작성해주었다. 우선은 수업 때 구조 그대로 작성한거였는데, 후에 DB 두 번째를 만들면서 코드를 작성해보니, 이렇게 연결 부분을 하나의 클래스로 만들어두면 같은 Schema에 든 두 개의 DB에 접근할 때, 연결하는 부분을 이 클래스의 connect() 메소드를 호출해서 처리할 수 있으니까 굉장히 편하다는 것을 다시금 깨달았다.
public class DBConnector {
private static final String DB_URL = "jdbc:mysql://127.0.0.1:3306/catchmind";
private static final String DB_USERNAME = "stdUser";
private static final String DB_PASSWORD = "wkvmtlf2";
public static Connection connectDB() {
Connection conn = null;
try {
conn =DriverManager.getConnection(DB_URL, DB_USERNAME, DB_PASSWORD);
System.out.println("DB 연결 성공");
} catch (SQLException e) {
System.out.println("DB 연결 실패");
e.printStackTrace();
}
return conn;
}
public static void main(String[] args) {
connectDB();
}
}
이후에 제시어를 담는 DB 테이블과 플레이어 정보를 관리하는 DB 테이블, 이렇게 두 개를 만들었다. (스키마 이름을 토대로 DBConnector에서 가져오고? 이제 각 Repository에서 작성하는 SQL 쿼리문에서 테이블명을 지정할 수 있다. SQL이나 DB 내용을 잘 몰라서 처음 수업 당시 실습할 때 이 부분이 헷갈렸었다.)

public class QuizWordRepository {
public String getRandomWord() {
String wordData = null;
try (Connection conn = DBConnector.connectDB()) {
int rowCount = 0;
String countSql = "SELECT COUNT(*) FROM catchmind_words";
try (PreparedStatement pstmt = conn.prepareStatement(countSql);
ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
rowCount = rs.getInt(1);
}
}
if (rowCount == 0) {
return null;
}
int randomIndex = new Random().nextInt(rowCount);
String sql = "SELECT name FROM catchmind_words LIMIT 1 OFFSET ?";
try (PreparedStatement pstmt2 = conn.prepareStatement(sql)) {
pstmt2.setInt(1, randomIndex);
try (ResultSet rs2 = pstmt2.executeQuery()) {
if (rs2.next()) {
wordData = rs2.getString("name");
}
}
}
} catch (SQLException e) {
System.out.println("단어 가져오기 실패");
e.printStackTrace();
}
return wordData;
}
}
제시어를 가져오기위한 repository는 위와 같이 작성해주었다. 우선은 가져오는 작업 정도만 구현했다.
public class PlayerRepository {
private final Connection conn;
public PlayerRepository () {
this.conn = DBConnector.connectDB();
System.out.println("플레이어 레포지토리 DB 연결 성공");
}
public Player findPlayerByName(String name) {
String sql = "SELECT * FROM player WHERE name = ?";
Player player = null;
try(PreparedStatement pstmt = conn.prepareStatement(sql);) {
pstmt.setString(1, name);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
System.out.println("플레이어 찾음!!!!!");
player = new Player();
player.setId(rs.getInt("id"));
player.setWin(rs.getInt("win"));
player.setPlayTime(rs.getInt("playtime"));
player.setName(name);
}
};
} catch (SQLException e) {
e.printStackTrace();
}
return player;
}
public void savePlayer (Player player) {
String insertSql = "INSERT into player (name) VALUES (?)";
try(PreparedStatement pstmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS);) {
pstmt.setString(1, player.getName());
pstmt.executeUpdate();
try (ResultSet generatedKeys = pstmt.getGeneratedKeys()){
if (generatedKeys.next()) {
int id = generatedKeys.getInt(1);
player.setId(id);
System.out.println(id + player.getName()+ "플레이어의 정보가 DB에 추가되었습니다.");
}
}
} catch (SQLException e) {
e.printStackTrace();
}
}
public void updatePlayer (Player player) {
String updateSql = "UPDATE player SET win = ?, playTime = ? WHERE id = ?";
try(PreparedStatement pstmt = conn.prepareStatement(updateSql);) {
pstmt.setInt(1, player.getWin());
pstmt.setInt(2,player.getPlayTime());
pstmt.setInt(3,player.getId());
pstmt.executeUpdate();
System.out.println(player.getName()+"의 점수가 DB에 업데이트되었습니다.");
} catch (SQLException e) {
e.printStackTrace();
}
}
public void deletePlayer (Player player) {
String deleteSql = "DELETE FROM player WHERE id = ?";
try(PreparedStatement pstmt = conn.prepareStatement(deleteSql);) {
pstmt.setInt(1,player.getId());
pstmt.executeUpdate();
System.out.println(player.getName()+"이 DB에서 삭제되었습니다.");
} catch (SQLException e) {
e.printStackTrace();
}
}
}
이후에 플레이어를 관리하는 DB 테이블은 수업 때 다뤘던 CRUD를 이용해보기에 좋은 DB라고 생각해서 우선 CRUD를 구현한 뒤에 게임 진행 코드에서 필요한 부분을 가져다 썼다.
+) 후기?
delete 메소드는 실상 쓸 곳이 없어졌다.. 다른 기능(예를 들면 탈퇴..?)을 구현하면 쓰게될 수도 있..겠지만, 일단은 여건 상 불가했다. DB 연결 작업을 마치고 생각한건 Player 클래스의 역할이었는데, Player가 모델이라기에 조금 애매한 느낌인 상태였다.
이 entity에서 메시지 송신을 담당해도 되는걸까..? 하는 의문이 들어서 이걸 뺐는데 이게 좋은 행동이었는지는 여전히 의문이긴하다. 메시지 송신은 순수하게 ConnectionController 쪽에서 담당하도록 바꾸었는데.., 이걸 바꾸면서 GameRoom에서 플레이어 리스트를 가지고 있던 부분도 바꿔야했고, 이러면 바꿔야하는 부분이 많았다.
(이렇게되면 사실 기존 코드에도 문제가 많았던게 아닐까..싶은...? -> 처음부터 코드 구조를 잘 잡아서 잘 짜는게 역시..)
이걸 바꾸면서 생긴 자잘한 오류를 수정하고-사실 끝까지 오류만 잡았다- 게임 종료를 그래도 구현해야할 거 같아서, 종료 시 플레이어들의 점수를 오름차순으로 출력되게 기능을 추가했다.
수정이랑 기능 추가를 같이해서 오류..?나 예상대로 안돌아가는 그런 문제가 많았다(둘을 천천히 나눠서 따로하면 좋았겠다..그런데 시간이 없었다..ㅎㅎ).
종료 공지라거나, 이걸 하면서 중간에 플레이어가 나가버리는 예외상황 생각해서 처리하는게 상당히 까다로웠다. (널 포인터 에러 잡는게 생각보다 오래 걸렸다. 좀 급하게해서...ㅎ)
나름 여러차례 수정하고, 자잘한 문제들을 수정해서 완료했다...
public class WinnerService {
public List<Map.Entry<ConnectionController,Integer>> getScoreList (List<ConnectionController> players) {
Map<ConnectionController,Integer> scoreBoard = new HashMap<>();
for (ConnectionController p : players) {
scoreBoard.put(p,p.getPlayer().getScore());
}
List<Map.Entry<ConnectionController, Integer>> entryList = new ArrayList<>(scoreBoard.entrySet());
entryList.sort(Map.Entry.comparingByValue(Comparator.reverseOrder()));
return entryList;
}
}
위에는 종료 시, 승자 판정하고, 결과 List로 만들어서 반환해주는 메소드이자 서비스이다. Map의 값 기준으로 sort할 수 없나 궁금해서 이건 AI(Gemini)한테 물어봤더니 정말 똑똑했다. Entry라는 맵에 이미있는 걸 사용해서-아마 수업 때 이거 배웠던 것 같다...ㅎ-리스트로 만들어주고, 거기서 sort하면 값대로 정렬되었던 것 같다. (처음에는 그냥 코드짜서 정렬시킬까했는데 어쨌든 sort써서 해보고 싶어서 물어봤다.)
이거를 반환받아서 게임 마지막 부분 로직을 거의 처리하는 식으로 진행됐다. 다른쪽에서 이 리스트를 받아서 클라이언트가 잘 받을수있는 메시지 형태로 변형시켜주고.., 다른 마무리 처리 등등을 진행했다.
public void endGame(GameRoom gameRoom) {
gameRoom.broadcastToRoom(("CHAT: 게임이 종료되었습니다."));
List<Map.Entry<ConnectionController,Integer>> scoreList = winnerService.getScoreList(gameRoom.getPlayers());
if(scoreList.isEmpty()) return;
String result = "RESULT:";
for (Map.Entry<ConnectionController,Integer> e : scoreList) {
System.out.println(e.getKey().getPlayer().getName()+" "+e.getValue());
}
boolean winner = true;
for (Map.Entry<ConnectionController, Integer> e : scoreList) {
result += e.getKey().getPlayer().getName()+" "+e.getValue()+"점:";
e.getKey().updatePlayerState(winner);
winner = false;
}
gameRoom.broadcastToRoom("CHAT:승자는 ["+scoreList.get(0).getKey().getPlayer().getName()+"] 입니다");
System.out.println(result);
gameRoom.broadcastToRoom(result);
}
이 코드가 근데 동점 승리자를 처리하지 못한다는 단점이있다. 사실 코드 몇 줄 추가하면 가능하겠지만-리스트 첫번째 값을 max에 저장하고 이게 같으면 winner true를 반환해주면..될 것 같다-, 이걸 쓰면서 생각났다...
public void nextRound(GameRoom gameRoom) {
gameRoom.stopTimer();
int round = gameRoom.getRound();
gameRoom.setRound(++round);
if (round > gameRoom.getPlayers().size()*3){
gameRoom.stopTimer();
endGame(gameRoom);
return;
}
...
게임을 종료시켜주기 위해서 다음 라운드를 진행시키는 메소드에 라운드 종료 판정 코드를 작성해주고,
public void removePlayer(ConnectionController p) {
if(players.size() <= 1) {
stopTimer();
System.out.println("남은 인원 1명 이하임");
gameService.endGame(this);
return;
}
else p.send("DISCONNECT:");
players.remove(p);
...
}
플레이어가 나가는걸 처리해주는 GameRoom 쪽에서 플레이어들이 한 명 빼고 전부 나가버리는 상황 정도를 예외처리할 수 있도록 코드 몇 줄 작성해주었다.
여기서 널 포인터 오류 잡는 일을 했다. 게임 종료 처리를 배열에서 플레이어 삭제하기 전에하는데도-물론 처음에는 아니었지만, 이건 빨리 찾았다- 왜 널포인터 오류가 나나 했는데, 앞에 말했던 ConnectionController랑 Player 역할..이런 부분 수정을 하면서 좀 꼬여서 그랬었던 것 같다. (꼬인거 푸는데 엄청 오래걸렸다^^...)
여기에 더해 종료 메시지 처리하기 위해서 커맨드 클래스를 또 만들고, 마무리 화면 띄우기 위해서 클라이언트 쪽에 마무리 UI를 만들었는데, 이 부분은 시간없어서 정말 그냥 텍스트만 순서대로 띄우는 화면으로 만들었다.
아 원래는 클라이언트 쪽에 몇 번 플레이했고, 몇 번 승리했는지 띄워주려고 DB 테이블을 구현했는데, 이건 DB에 기록만 되고, 띄우는건 사실 구현하지 못했다. 일단..기록은 되니까 그걸로 만족하기로 했다. 생각보다..? DB랑 MySqlWorkBench에 익숙해지니 DB보는건 재밌었다. 처음엔 영어고, 무슨 용어인지 몰라서 감이 안왔었다..


그렇게해서 어찌저찌..? 조금 애매한 것 같긴하지만 마무리가 되었다... 시험이 몰려있어서 빨리 끝내려고 나름 서둘렀는데, 사실 나름 빨리했던 것 같은데 결국 마지막에 급하게 또 오류가 생기고 수정해서 마음이 아팠다...