프레임워크/Spring

[Spring Boot] 오목 게임 만들기 - Day 1

munsik22 2026. 2. 21. 15:15

🎯 프로젝트 목표

  1. Java 언어를 활용한 멀티스레드 프로그램 구현
  2. Spring Boot의 계층 구조 학습
  3. 클라이언트-서버 구조의 간단한 웹 서버 구현

🎮 게임 로직 구현 (파이썬)

더보기
import sys

N = 19
DIRS = ((0, 1), (1, 0), (1, 1), (-1, 1))  # 가로/세로/우하/우상

def count_consecutive(board, x, y, dx, dy, p):
    cnt = 1
    nx, ny = x + dx, y + dy
    while 0 <= nx < N and 0 <= ny < N and board[nx][ny] == p:
        cnt += 1
        nx += dx
        ny += dy
    nx, ny = x - dx, y - dy
    while 0 <= nx < N and 0 <= ny < N and board[nx][ny] == p:
        cnt += 1
        nx -= dx
        ny -= dy
    return cnt

def get_line(board, x, y, dx, dy, p, span=5):
    # 0: 빈칸, 1: 내 돌, 2: 막힘(상대/경계)
    line = []
    for k in range(-span, span + 1):
        nx, ny = x + k * dx, y + k * dy
        if 0 <= nx < N and 0 <= ny < N:
            v = board[nx][ny]
            if v == p:
                line.append(1)
            elif v == 0:
                line.append(0)
            else:
                line.append(2)
        else:
            line.append(2)
    return line

def makes_open_four(line, center, newpos):
    # 패턴: 0 1 1 1 1 0 (활사). 가운데/새 돌이 4개의 1 구간 안에 포함되어야 함
    L = len(line)
    for s in range(L - 5):  # 길이 6 윈도우
        if line[s] == 0 and line[s + 5] == 0 and line[s + 1:s + 5] == [1, 1, 1, 1]:
            stones = range(s + 1, s + 5)
            if center in stones and newpos in stones:
                return True
    return False

def has_four_in_direction(board, x, y, dx, dy, p):
    # 길이 5 윈도우에 내 돌 4 + 빈칸 1 (한 수로 5목 가능) 이 있으면 '사'로 간주
    line = get_line(board, x, y, dx, dy, p, span=5)
    center = 5
    L = len(line)
    for s in range(L - 4):  # 길이 5 윈도우
        if s <= center <= s + 4:
            w = line[s:s + 5]
            if w.count(1) == 4 and w.count(0) == 1:
                return True
    return False

def has_open_three_in_direction(board, x, y, dx, dy, p):
    # 빈칸 하나를 더 두면 활사(0 1111 0)가 되는 경우가 있으면 '활삼'으로 간주
    line = get_line(board, x, y, dx, dy, p, span=5)
    center = 5
    for t, v in enumerate(line):
        if v != 0:
            continue
        line[t] = 1  # 다음 수 가정
        ok = makes_open_four(line, center, t)
        line[t] = 0
        if ok:
            return True
    return False

def is_forbidden_black_move(board, x, y):
    # board[x][y] == 1(흑) 이 이미 놓여있다고 가정
    # 1) 장목(6+) 금지
    for dx, dy in DIRS:
        if count_consecutive(board, x, y, dx, dy, 1) >= 6:
            return True, "장목(6목 이상)"

    # 2) 정확히 5목이면 즉시 승리(금수로 보지 않음)
    for dx, dy in DIRS:
        if count_consecutive(board, x, y, dx, dy, 1) == 5:
            return False, None

    # 3) 4-4 / 3-3 금지(흑만)
    fours = 0
    threes = 0
    for dx, dy in DIRS:
        if has_four_in_direction(board, x, y, dx, dy, 1):
            fours += 1
        else:
            if has_open_three_in_direction(board, x, y, dx, dy, 1):
                threes += 1

    if fours >= 2:
        return True, "4-4 금수"
    if threes >= 2:
        return True, "3-3 금수"
    return False, None

def check_winner(board, x, y):
    p = board[x][y]
    for dx, dy in DIRS:
        cnt = count_consecutive(board, x, y, dx, dy, p)
        if p == 1:
            if cnt == 5: # 흑: 정확히 5
                return 1
        else:
            if cnt >= 5: # 백: 5 이상
                return 2
    return 0

def print_board(board):
    sys.stdout.write("\n".join(" ".join(map(str, row)) for row in board) + "\n\n")

def main():
    board = [[0] * N for _ in range(N)]
    empties = N * N
    player = 1
    move_count = 0

    while empties:
        sys.stdout.write("입력: x y (0~18)\n")
        sys.stdout.write(f"Player {player}'s Turn (1=흑, 2=백)\n")
        sys.stdout.flush()

        while True:
            line = sys.stdin.readline()
            if not line:
                return
            try:
                x, y = map(int, line.split())
            except ValueError:
                sys.stdout.write("[Error] 입력은 'x y' 형태로 해주세요.\n")
                continue

            if not (0 <= x < N and 0 <= y < N):
                sys.stdout.write("[Error] Invalid position\n")
                continue
            if board[x][y] != 0:
                sys.stdout.write("[Error] Already located\n")
                continue

            board[x][y] = player

            # 렌주룰: 흑(1)만 금수 체크
            if player == 1:
                forbidden, reason = is_forbidden_black_move(board, x, y)
                if forbidden:
                    board[x][y] = 0
                    sys.stdout.write(f"[Renju] 금수입니다: {reason}\n")
                    continue

            break

        empties -= 1
        move_count += 1
        print_board(board)

        if move_count >= 9:
            winner = check_winner(board, x, y)
            if winner:
                sys.stdout.write(f"Player {winner} Win!\n")
                return

        player = 3 - player

    sys.stdout.write("Draw!\n")

if __name__ == "__main__":
    main()

💻 프로젝트 생성

  • 프로젝트 설정: 타입 - Gradle (Groovy) / JDK - corretto-17
  • 프로젝트 의존성
    • 웹 서버 역할을 해야 하므로 일단 Spring Web, Spring Web Service을 추가함
    • Getter, Setter 사용을 위해 Lombok을 추가함

📁 프로젝트 계층 구조

ohmok/
  build.gradle
  settings.gradle
  src/
    main/
      java/
        com/example/ohmok/
          OhmokApplication.java            # 애플리케이션 시작점(부트스트랩)
          controller/
            GameController.java            # API(입력/출력) 계층
          dto/
            MoveRequest.java               # 요청 DTO
            GameStateResponse.java         # 응답 DTO
          service/
            GameService.java               # 유스케이스/흐름 제어(서비스 계층)
            RenjuRules.java                # 렌주룰 판정(도메인 로직 성격)
          model/
            Game.java                      # 게임 상태(도메인 모델)
          exception/
            ApiException.java              # 도메인/서비스에서 던지는 예외
            GlobalExceptionHandler.java    # 예외를 HTTP 응답으로 매핑
      resources/
        application.properties
    test/
      java/...

 

일반적으로 Spring Boot 애플리케이션의 계층 구조는 유지보수성과 확장성을 높이기 위해 다음과 같이 역할별로 구분된다.

  1. Controller 계층
    ▪ 클라이언트(웹, 모바일 등)로부터 들어오는 HTTP 요청을 처리
    요청을 적절한 서비스에 전달하고, 서비스에서 처리된 결과를 응답으로 반환
    @RestController, @RequestMapping 등의 애노테이션 사용
  2. Service 계층
    비즈니스 로직을 담당
    여러 데이터 조작이나 검증, 트랜잭션 관리가 이뤄짐
    Controller와 데이터 접근 계층(Repository) 사이에서 중재자 역할 수행
    주로 @Service 애노테이션이 붙음
  3. Repository (또는 DAO) 계층 - 이 프로젝트에서는 사용되지 않았지만 나중에 추가될 수도 있음
    데이터베이스와 직접 연결되어 데이터를 저장, 조회, 수정, 삭제하는 역할
    JPA, MyBatis, JdbcTemplate 등을 사용
    @Repository 애노테이션으로 표시
  4. Domain (Model) 계층
    데이터베이스 테이블과 매핑되는 엔티티 클래스들이 위치
    객체지향적으로 데이터와 상태, 동작을 포함할 수도 있음
    DTO와 구분되며, 주로 DB와 연동되는 실제 데이터 구조

이 프로젝트에서 계층 간 의존 방향은 아래와 같이 위에서 아래로 이동한다.

Controller → Service → Domain(Model/Rules)

 

1. Presentation Layer (controller, dto)

  • GameController
    • HTTP 요청을 받고 (POST /games/{id}/moves)
    • 입력을 최소 검증한 뒤
    • GameService에 위임하고
    • 결과를 DTO로 반환
  • DTO(MoveRequest, GameStateResponse)
    • API 입출력 전용 구조체
    • 내부 도메인 모델(Game)을 그대로 노출하지 않게 해줌

2. Application/Service Layer (service)

  • GameService
    • 게임 생성, 한 수 두기, 게임 조회 같은 유스케이스를 담당함
    • 턴 변경, 착수 가능 여부, 종료 처리 같은 흐름/상태 전이를 여기서 제어함
    • 렌주 룰 체크(RenjuRules)와 승리 판정을 호출함
    • 현재는 ConcurrentHashMap으로 게임을 메모리에 보관함

3. Domain Layer (model, rules)

  • Game (model)
    • 보드, 현재 플레이어, 승패, 수 카운트 같은 핵심 상태를 가짐
  • RenjuRules
    • “흑 금수(3-3/4-4/장목)”, “승리 조건(흑=정확히5, 백=5이상)” 같은 비즈니스 규칙을 담음
    • 가능하면 Spring 의존성 없이 순수 로직으로 유지하는 게 좋음

4. Infrastructure

  • DB, 캐시, 메시지큐 같은 인프라가 아직 없음
  • 게임 저장소가 메모리(Map) 로 되어 있어 단일 서버에서만 유지됨
  • 나중에 Redis/DB로 바꾸려면 GameService에 GameRepository 같은 인터페이스를 두고 코드를 교체하는 구조로 확장해야 함

코드 구현

 

first commit · qkrwns1478/oh-mok@f59c6b2

+ testImplementation 'org.springframework.boot:spring-boot-starter-webservices-test'

github.com

사용 예시

# 게임 생성
curl -X POST localhost:8080/api/games

# 수 두기 (x,y는 0~18)
curl -X POST localhost:8080/api/games/{gameId}/moves \
  -H "Content-Type: application/json" \
  -d '{"x":10,"y":10}'

📚 오늘의 TIL

1. Java의 Record 클래스

Java 14 이후 버전부터 도입된 record는 데이터를 담는 간단한 불변(immutable) 객체를 쉽게 정의하기 위한 새로운 클래스 유형이다. 주로 값 객체(Value Object)를 만들 때 사용한다.

  • 불변 객체: 모든 필드는 기본적으로 final이며, 객체 생성 후 변경이 불가능함
  • 간결한 문법: 필드 선언, 생성자, getter, equals(), hashCode(), toString() 메서드를 자동으로 생성해 코드가 매우 간결해짐
  • 사용 용도
    • 단순 데이터 전달 객체(DTO)
    • 불변성을 보장해야 하는 값 객체
    • 코드 간결성과 안전성을 동시에 추구할 때
  • 일반 클래스와의 차이점
    • 일반 클래스: 데이터뿐 아니라 다양한 행위(메서드)를 포함할 수 있고, 변경 가능한 상태를 가질 수 있다. 복잡한 객체를 설계할 때 주로 사용한다.
    • record 클래스: 주로 불변(immutable) 값 객체로서 데이터만 담기 위해 간결하게 설계된 클래스다. 코드량을 크게 줄이고, 데이터 객체 설계에 최적화돼 있다.
  • 일반 클래스 사용 예시
public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    // equals(), hashCode(), toString() 직접 작성 필요
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && name.equals(person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}
  • record 클래스 사용 예시
public record Person(String name, int age) {}
  • 주요 차이점 정리
구분 일반 클래스 레코드 클래스
코드량 생성자, getter, equals, hashCode, toString 직접 작성 선언만으로 자동 생성됨
불변성 원하는 경우 final 필드를 직접 지정해야 함 기본적으로 모든 필드가 final이고 불변
가독성 및 간결함 코드가 길고 반복적 매우 간결하고 명확
확장성 메서드 추가와 상태 변경 가능 기본적으로 불변, 메서드 재정의 가능하지만 상태 변경은 불가
목적 복잡하거나 상태 변하는 객체 설계에 적합 단순 데이터 운반 및 불변 값 객체에 적합

2. Lombok

Lombok은 Java 개발에서 반복되는 보일러플레이트 코드를 줄여주는 라이브러리다. getter, setter, 생성자, equals, hashCode, toString 메서드를 자동으로 생성함으로써 코드가 훨씬 간결해지고, 개발 생산성이 향상된다.

  • 컴파일 시점에 애노테이션을 처리하여 코드에 필요한 부분을 자동 생성함
  • @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor, @ToString, @EqualsAndHashCode, @Data 등 다양한 애노테이션 제공
  • 코드 가독성 향상과 유지보수 효율성 증가에 도움
  • 하지만 내부적으로는 비공개 컴파일러 API에 의존하는 등 일부 주의점이 있다.

  Lombok 사용 전 예시

package com.example.ohmok.model;

public class Game {
    public static final int N = 19;

    private final String id;
    private final int[][] board = new int[N][N];

    private int currentPlayer = 1; // 1=흑
    private boolean finished = false;
    private int winner = 0;        // 0=없음/무승부
    private int moveCount = 0;
    private int empties = N * N;

    public Game(String id) {
        this.id = id;
    }

    public String getId() { return id; }
    public int[][] getBoard() { return board; }

    public int getCurrentPlayer() { return currentPlayer; }
    public void setCurrentPlayer(int currentPlayer) { this.currentPlayer = currentPlayer; }

    public boolean isFinished() { return finished; }
    public void setFinished(boolean finished) { this.finished = finished; }

    public int getWinner() { return winner; }
    public void setWinner(int winner) { this.winner = winner; }

    public int getMoveCount() { return moveCount; }
    public void incMoveCount() { this.moveCount++; }

    public int getEmpties() { return empties; }
    public void decEmpties() { this.empties--; }
}

 

▪ Lombok 적용 후 예시

package com.example.ohmok.model;

import lombok.Getter;
import lombok.Setter;

@Getter
public class Game {
    public static final int N = 19;

    private final String id;
    private final int[][] board = new int[N][N];

    @Setter
    private int currentPlayer = 1; // 1=흑
    @Setter
    private boolean finished = false;
    @Setter
    private int winner = 0;        // 0=없음/무승부
    private int moveCount = 0;
    private int empties = N * N;

    public Game(String id) {
        this.id = id;
    }

    public void incMoveCount() { this.moveCount++; }

    public void decEmpties() { this.empties--; }
}

📋 TO-DO 리스트

  • Web GUI 구현
  • 클라이언트-서버 로직 분리