열렬히.뛰기

딴짓천국 설계하기 (4) — Docker 복구, Room 도메인, 그리고 첫 단위 테스트

말썽인 Docker

RoomRepository를 작성하려고 다시 작업을 시작했는데, 지난번과 같은 Docker 네트워크 오류부터 만났다.

Docker는 왜 또 말썽이었나

make를 실행하자 전과 같은 오류가 났다.

Error response from daemon: failed to set up container networking:
network ... not found

"왜 또 같은 오류가 날까?"부터 확인했다.

원인은 컨테이너가 참조하는 네트워크 ID와 현재 Docker가 들고 있는 네트워크 ID가 달랐던 것이다.

네트워크 이름이 breakground_default로 같아도, 새로 만들어지면 ID는 다르다.

남아 있던 컨테이너는 예전 네트워크 A를 찾는데 현재 Docker에는 새 네트워크 B만 있었던 상황이다.

Makefile 문법이 틀린 게 아니라, Docker 명령 자체가 실패한 결과를 make가 그대로 보여준 것이었다.

다만 네트워크가 왜 사라졌는지 그 근본 원인은 아직 확인하지 못했다.

복구는 컨테이너를 재생성하는 것으로 해결했다.

bash
docker compose up -d --force-recreate postgres

데이터는 컨테이너 내부가 아니라 postgres_data라는 named volume에 있으므로, 이 명령은 볼륨을 지우지 않고 컨테이너만 새로 만든다.

복구 후 기존 Flyway 이력과 room 테이블까지 확인했다.

다만 이건 평소 시작마다 쓸 명령은 아니고, 실행 중인 DB도 재시작시키므로 복구용으로만 둔다.

Docker 오류 - 원인 찾기

처음에는 지난번 Docker Desktop을 수동으로 종료한 게 원인이 아닐까 의심했다.

그런데 직접 종료하는 것 자체는 정상적인 사용법이라 이것만으로 원인이라 단정할 수 없었다.

make fclean으로 컨테이너와 네트워크를 함께 정리하면 재발을 막을 수 있다는 설명을 들었지만, 실제로는 정리·종료 과정 자체가 멈췄다.

그래서 "정리 명령이 왜 끝나지 않는가"로 질문을 옮겨 추적했다.

make fclean의 원래 순서는 Compose 정리 후 macOS에 앱 종료를 요청하는 것이다.

docker-compose down
→ osascript -e 'quit app "Docker"'

로그를 보면 Container와 Network가 Removed로 찍혀 있었지만, 그게 명령 전체의 완료를 보장하진 않았다.

실제로 확인해보니 docker-compose down 자체가 끝나지 않고 대기 중이었다.

앞 명령이 안 끝나면 뒤의 종료 명령까지 갈 수가 없다.

처음 실행은 프롬프트로 돌아왔고 다음 실행은 down에서 멈췄으니, 이 둘을 같은 상태로 묶어서 설명하는 것도 조심해야 했다.

Docker 전용 종료 명령도 써봤다.

bash
docker desktop stop --timeout 30

이것도 30초 뒤 실패했고, GUI Helper·Docker Desktop·com.docker.backend 같은 프로세스가 남아 있다는 오류가 떴다.

정상 종료 요청이 제한 시간 안에 끝나지 않았다는 것은 확인했지만, 내부 어디서 멈췄는지는 모른다.

우선 강제 종료는 됐다.

bash
docker desktop stop --force --timeout 30

그런데 다시 make를 돌리니 오래된 네트워크를 찾는 오류는 그대로 남아 있었다.

여기서 또 하나의 의문이 생겼다 — "프로세스를 강제로 죽였는데 왜 네트워크 오류는 그대로일까?"

답은 프로세스를 강제로 죽이는 것과 컨테이너의 연결 정보를 복구하는 것이 별개의 문제라는 것이었다.

결국 이번에도 컨테이너 재생성으로 복구했다.

Makefile을 어떻게 다시 나눴나

일반 시작, 복구, 종료를 명확히 구분했다.

명령 역할
make Docker 실행 → 엔진 준비 대기 → DB 실행
make repair-db 볼륨을 유지하고 DB 컨테이너 재생성
make down 컨테이너와 네트워크 정리. 데이터 볼륨 유지
make fclean down 성공 후 Docker Desktop 종료

시작 순서는 up → wait-for-docker → docker-start로 의존 관계를 걸었고, fclean은 down이 성공한 뒤에만 docker-stop을 호출하게 했다.

make -j로 돌려도 준비·실행·정리·종료가 뒤섞이지 않도록 하기 위해서다.

reset-db, nuke-db는 down -v를 써서 데이터까지 지우므로 복구 명령과 혼동하지 않게 이름을 분리했다.

이 변경이 Docker 내부 문제를 고친 건 아니다.

실행 순서와 실패했을 때 무엇을 해야 하는지를 명확하게 만든 것뿐이다.

처음 수정에서는 명령이 멈췄을 때 끊기 위해 Python 시간 제한 스크립트를 추가했었다.

그런데 Docker 명령 몇 개 돌리려고 별도 스크립트를 관리하는 건 이 프로젝트 규모에 과하다고 판단해 제거했다.

justfile로 바꾸는 것도 검토했지만, 도구를 바꾼다고 Docker가 멈추는 문제 자체가 해결되진 않는다.

그래서 지금은 Makefile을 유지하고 명령을 작게 두는 쪽을 택했다.

대신 포기한 것도 있다 — Docker 준비 대기와 Compose 명령 전체에는 시간 제한이 없다.

거기서 멈추면 Ctrl+C로 끊고 Docker 상태를 직접 확인해야 한다.

확인한 점

  • make가 실패했다고 Makefile 문법이 문제인 건 아니고 실패한 실제 명령부터 봐야 한다.
  • 컨테이너·네트워크·볼륨은 따로 생각해야 한다.
  • 진행 메시지와 명령의 실제 종료는 별개다.

RoomRepository 만들기

Repository, DAO, DTO, VO는 뭐가 다른가

Docker를 복구하고 나서야 원래 하려던 RoomRepository로 돌아왔다.

Room은 방 하나의 상태와 규칙을 표현하고, RoomRepository는 그걸 저장·조회하는 역할이다.

@Entity는 테이블과 연결되는 매핑 정보일 뿐, 객체가 스스로 조회 기능을 갖게 만드는 건 아니다.

..암튼 DAO, Repository, DTO, VO가 헷갈려서 정리했다.

절반은 "책임"이고 절반은 "데이터를 담는 그릇"이라는 축으로 나누면 이해가 쉬운 거 같다.

구분 역할 이 프로젝트에서의 예
DAO 데이터 접근 방법을 감춤 예전 JDBC MemberDao
Repository 도메인 객체를 저장하고 찾는 통로 RoomRepository
Entity 식별자로 구분되는 도메인 객체 Room
DTO 경계를 넘어 전달할 데이터 구조 향후 RoomResponse
VO 값 자체로 동등성을 판단 LocalTime

지금은 RoomRepository 하나로 충분해서 같은 일을 하는 RoomDao를 따로 둘 이유가 없다.

RoomRepository — 타입 파라미터 하나 어긋난 채로 빌드는 됐다

java
public interface RoomRepository extends JpaRepository<Room, Long> {
}

JpaRepository<T, ID>의 두 타입 파라미터는 "어떤 Entity를, 어떤 타입의 식별자로 찾을지"를 컴파일 타임에 명시하는 역할이다.

Long이 고정값은 아니고 DB PK에 BIGINT를 많이 써서 관습적으로 Long을 쓸 뿐이다.

그런데 Room.id는 Integer인데 처음엔 JpaRepository<Room, Long>으로 선언해 타입이 어긋나 있었다.

메소드를 하나도 호출하지 않아서 컴파일은 됐지만, 이후 findById()를 호출하면 문제가 드러날 걸로 예상됐고 실제로 나중에 JpaRepository<Room, Integer>로 고쳤다.

인터페이스에 추상 메소드가 하나도 없는 것도 처음엔 의아했다.

왜 메소드 하나 없이 save(), findAll(), findById()가 동작할까 궁금해서 상속 구조를 따라가봤다.

JpaRepository는 PagingAndSortingRepository, CrudRepository를 상속하고 있어 이 메소드들을 이미 물려받는다.

구현체는 Spring이 실행 시점에 동적 프록시로 자동 생성한다.

이게 IoC(제어의 역전)라는 큰 원칙이 Repository에 적용된 구체적인 사례다.

IoC는 "객체 생성·관리 책임을 프레임워크가 갖는다"는 원칙이고, 동적 프록시 생성은 그 원칙을 실현하는 기술적인 수단이다.

isOpen() — 자정을 넘는 운영 시간에서 두 번 틀렸다

docs/decisions.md에는 이미 "자정을 넘는 운영 시간을 허용한다"는 결정이 있었는데, 처음 작성한 코드는 이 케이스를 놓쳤다.

java
public boolean isOpen() {
    LocalTime now = LocalTime.now();
    return now.isAfter(openAt) && now.isBefore(closeAt);
}

openAt=18:00, closeAt=02:00, now=23:00이면 now.isBefore(closeAt)는 false다.

LocalTime은 날짜 정보 없이 하루 안에서만 비교하므로 23:00은 02:00보다 항상 "이후"로 취급된다.

"그럼 컬럼 타입이나 스키마를 바꿔야 하나?" 하는 생각도 들었지만, 해결은 그쪽이 아니라 openAt과 closeAt의 대소 관계로 분기하는 것이었다.

  • 일반적인 방(openAt < closeAt): now가 openAt 이후이면서 closeAt 이전이면 열림 (AND)
  • 자정을 넘는 방(openAt > closeAt): now가 openAt 이후이거나 closeAt 이전이면 열림 (OR)

이 분기를 구현하면서 조건이 뒤바뀌는 실수를 두 번 했다.

일반 방 분기에서 isBefore/isAfter를 반대로 써서 12:00에 열려야 할 방(11:30~13:00)이 닫힌 걸로 나왔고, 자정 분기를 고치다가 조건을 거꾸로 써서 10:00에 닫혀야 할 방이 열린 걸로 나왔다.

각각 구체적인 시각을 대입해 손으로 검증하면서 잡았다.

java
public boolean isOpen() {
    LocalTime now = LocalTime.now();
    if (openAt.compareTo(closeAt) < 0)
        return now.isAfter(openAt) && now.isBefore(closeAt);
    else
        return now.isAfter(openAt) || now.isBefore(closeAt);
}

RoomService — 생성자 주입과 필터링 위치

@Autowired 필드 주입 대신 생성자 주입을 택한 이유는 세 가지다.

  1. 필드 주입은 final을 못 써서 불변성이 깨지고,
  2. Spring 컨테이너 없이 new RoomService(mockRepository)처럼 테스트하기 어렵고,
  3. 순환 의존성이 런타임에야 드러나는 경우가 있다.

그래서 "생성자 + final 필드"를 정석으로 두고 Lombok의 @RequiredArgsConstructor로 생성자 작성을 생략했다.

열린 방만 거를 때 DB에서 필터링할지 자바 스트림으로 거를지도 고민했다.

DB 필터링은 데이터가 많을 때 인덱스로 빠르게 거를 수 있지만 자정 통과 조건을 SQL WHERE절로 표현하기 번거롭다.

스트림 필터링은 이미 만든 Room.isOpen()을 그대로 재사용해 로직이 한 곳에 모인다.

Room이 점심/간식/퇴근방처럼 몇 개 안 되는 지금은 스트림 필터링을 택했다.

이 로직이 RoomService 내부에 캡슐화되어 있으므로, 나중에 방이 많아져 DB 필터링으로 바꿔도 이 메소드를 호출하는 Controller는 영향을 받지 않는다.

그런데 테스트를 짜려다 보니 걸리는 부분이 있었다 — Room.isOpen()이 내부에서 LocalTime.now()를 직접 호출하면, 테스트에서 "오후 11시라고 가정"하는 식으로 시각을 강제할 수 없다.

Room.isOpen(LocalTime now)로 바꿔 바깥(RoomService)에서 시각을 넘기도록 수정했다.

java
// Room.java
public boolean isOpen(LocalTime now) {
    if (openAt.compareTo(closeAt) < 0)
        return now.isAfter(openAt) && now.isBefore(closeAt);
    else
        return now.isAfter(openAt) || now.isBefore(closeAt);
}
java
// RoomService.java
@Service
@RequiredArgsConstructor
public class RoomService {
    private final RoomRepository roomRepository;

    public List<Room> findOpenRooms(LocalTime now) {
        return roomRepository.findAll()
                .stream()
                .filter(room -> room.isOpen(now))
                .toList();
    }
}

"현재 시각에 방이 열려있는지"를 Room에 둘지 Service에 둘지도 생각해봤는데, 정답이 고정된 문제는 아니다.

기준은 "isOpen 계산이 Room 자신의 필드만으로 끝나는 순수 계산인가"다.

지금은 openAt, closeAt만으로 끝나는 순수 계산이라 Room에 두는 쪽이 자연스럽지만, 나중에 관리자가 강제로 방을 닫는 정책이 생기면 Service로 옮기는 걸 재검토할 수 있다.

RoomController — final 안 붙여서 또 주입이 안 됐다

처음 작성에서 두 가지 문제가 있었다.

@Autowired를 깜빡해서 roomService가 null로 남았고, return을 빼먹어서 컴파일 에러가 났다.

return은 바로 고쳤고, 주입 문제는 @RequiredArgsConstructor + private final RoomService roomService로 해결하려 했는데, 처음엔 final을 안 붙여서 여전히 주입이 안 됐다.

"왜 final을 빼면 주입이 안 될까?"를 따라가보니, @RequiredArgsConstructor가 final(또는 @NonNull)이 붙은 필드만 생성자에 넣어주는 건 Lombok의 임의적인 선택이 아니라 Java 언어 규칙에서 나온다는 걸 알게 됐다.

final 필드는 생성자에서 반드시 한 번 초기화해야 하는 필드라는 뜻이고, Lombok은 그 규칙을 그대로 따라 "필수(Required)" 필드를 판단한다.

java
@RestController
@RequiredArgsConstructor
public class RoomController {
    private final RoomService roomService;

    @GetMapping("/rooms")
    public List<Room> getRooms() {
        LocalTime now = LocalTime.now();
        return roomService.findOpenRooms(now);
    }
}

Room → RoomRepository → RoomService → RoomController까지 한 흐름이 이어졌다.

이름은 RoomController로 지었다.

@RestController 애노테이션 자체가 REST API임을 명시하므로 클래스 이름에 Rest를 또 붙이는 건 정보 중복이라 판단했다.

SSR 뷰 컨트롤러는 Next.js 쪽에서 담당하기로 했으므로(JSP로 SSR 취준 경험도 쌓고 싶었지만, AGENTS.md의 "기능을 넣기 위해 기술을 억지로 사용하지 않는다"는 원칙과 충돌해 Java_101 쪽 학습 프로젝트로 분리했다) RoomController/RoomRestController를 구분할 상황 자체가 없다.

DTO 적용 — RoomResponse

GET /rooms가 Room 엔티티를 그대로 반환하고 있던 걸 DTO로 분리했다.

DTO를 만든다고 DB 테이블이 느는 게 아니라, 엔티티와 API 응답을 분리하는 Java 객체 하나가 늘어나는 것이다.

room 테이블 → Room 엔티티 → RoomResponse → JSON 응답

이름은 RoomResponseDto가 아니라 RoomResponse로 지었다.

dto 패키지 안에 두면 분류는 위치로 이미 드러나니, 클래스 이름은 "Room을 조회한 HTTP 응답"이라는 역할을 더 구체적으로 말해주는 쪽을 택했다.

java
public RoomResponse(Room room) {
    this.id = room.getId();
    this.name = room.getName();
    this.openAt = room.getOpenAt();
    this.closeAt = room.getCloseAt();
}

변환은 Service의 Stream 파이프라인에 map 단계를 추가해서 처리했다.

java
.filter(room -> room.isOpen(now))
.map(RoomResponse::new)
.toList();

RoomResponse::new는 각 Room에 대해 new RoomResponse(room)을 실행하는 생성자 메서드 참조다.

Service의 반환 타입을 List<RoomResponse>로 바꾸면서 Controller의 반환 타입도 같이 맞췄다.

컴파일은 통과했고, Spring 기동 테스트는 처음엔 Docker Desktop이 꺼져 있어 PostgreSQL 연결 실패로 막혔다가 Docker를 켠 뒤 통과했다 — DTO 코드 문제가 아니라 환경 문제였다.

운영 시간 경계 정하기

[open_at, close_at) 구간으로 결정했다.

시작 시각은 포함하고 종료 시각은 제외한다.

12:00~13:00 방은 12:00 <= now < 13:00일 때 열려 있다.

자정을 넘는 18:00~02:00 방은 now >= 18:00 또는 now < 02:00일 때 열려 있다.

같은 시작·종료 시각을 가진 24시간 방은 허용하지 않는다.

isOpen()은 시작 시각에 !now.isBefore(openAt), 종료 시각에 now.isBefore(closeAt)를 쓰도록 맞췄다.

같은 시각 금지는 V2 Flyway migration에서 CHECK (open_at <> close_at) 제약으로 추가했다.

Flyway V2~V4 — 제약, 초기 데이터, 요일 정책

V1은 이미 적용된 상태라 건드리지 않고, V2에서 운영 시간 제약을 추가했다.

flyway_schema_history를 조회해 V1, V2가 모두 success = true인 것을 확인했다.

V3에는 초기 방 데이터를 넣었다.

점심메뉴방(10:0011:30), 저녁메뉴방(16:0017:30), 야근메뉴방(21:00~02:00) 세 개로 시작했다.

초안에는 세 가지 실수가 있었다.

id를 INSERT에 직접 적었는데 id 컬럼은 GENERATED ALWAYS AS IDENTITY라 DB가 값을 생성하므로 제외해야 했고, 방 이름과 time 값을 작은따옴표로 감싸지 않았다.

수정 후 테스트가 통과했고 PostgreSQL을 직접 조회해 세 방이 정확한 시각으로 들어간 걸 확인했다.

실제 GET /rooms를 일요일 오전 08:26에 호출해보니 세 방 모두 운영 시간 밖이라 빈 배열 []이 반환됐다.

이게 의도한 동작인지 확인하고 싶어서, Flyway migration에는 넣지 않는 로컬 임시 row(주말테스트방, 00:00~23:59)를 추가해 흐름을 재확인한 뒤 바로 삭제했다.

json
[{"closeAt":"23:59:00","id":4,"name":"주말테스트방","openAt":"00:00:00"}]

그런데 여기서 정책 구멍이 드러났다.

초기 세 방은 평일에만 운영해야 하는데, 현재 Room에는 open_at, close_at만 있어 요일을 판단할 정보가 없었다.

토요일·일요일에도 시각만 맞으면 열려버리는 상태였다.

요일 운영 정보를 별도 RoomOperatingDay 테이블로 뺄지, Room에 Boolean 컬럼을 추가할지 고민했다.

요일 조합이 임의로 다양해야 한다면 별도 테이블이 맞지만, 지금 요구사항은 평일/주말만 구분하면 되므로 Room에 weekday_open, weekend_open 두 Boolean 컬럼을 추가하는 더 작은 설계를 택했다.

둘 다 false인 방은 DB CHECK 제약으로 막기로 했다.

isOpen()은 LocalDateTime 하나를 받아 날짜와 시간을 함께 판단하도록 바꿨다.

일반 구간은 당일 운영 여부와 시간을 함께 확인하고, 자정 통과 구간은 21:00 이후엔 당일 운영 여부를, 02:00 전엔 date.minusDays(1)로 전날 운영 여부를 확인한다.

금요일 21:00~토요일 02:00 방이 토요일 01:00에도 "금요일에 시작한 운영 구간"으로 열려야 하기 때문이다.

V4 migration은 nullable 컬럼을 먼저 추가하고 기존 row를 weekday_open = true, weekend_open = false로 채운 뒤 NOT NULL을 걸고, CHECK (weekday_open OR weekend_open)을 추가했다.

처음 작성한 V4 초안에는 CASE 구문에 세미콜론이 잘못 들어가고 WEEKDAU_OPEN 오타가 있었는데, 애초에 DEFAULT값으로 기존 row를 채우는 방식을 택하면서 이 복잡한 UPDATE 자체가 필요 없어졌다.

테스트 통과, V4 성공 기록, 세 방 모두 평일 true·주말 false 저장까지 확인했다.

Room 생성자를 두 개로 나눈 이유

V4에서 추가한 정책을 검증하려면 DB 없이 Room 하나를 직접 만드는 단위 테스트가 필요했다.

그러려면 JPA가 요구하는 no-argument 생성자와는 별개로, name·시간·운영 요일을 받는 도메인 생성자가 있어야 했다.

java
public Room(String name, LocalTime openAt, LocalTime closeAt, boolean weekdayOpen, boolean weekendOpen) { ... }
protected Room() { }

@AllArgsConstructor는 id까지 포함한 생성자를 만들어버려서 적합하지 않았다.

id는 DB가 생성하는 값이라 테스트나 생성 코드가 지정하면 안 되기 때문이다.

기본 생성자를 왜 protected로 둬야 하는지도 궁금해서 찾아봤다.

Hibernate가 Entity를 DB row에서 복원할 때 이 생성자를 reflection으로 호출하기 때문이다.

JPA 명세가 Entity에 public 또는 protected no-argument 생성자를 요구하고, Hibernate는 그 명세를 구현한 provider로서 일반 코드처럼 생성자를 직접 호출하는 게 아니라 @Entity, @Id, @Column annotation을 읽고 reflection으로 인스턴스를 만든 뒤 필드를 채운다.

protected는 Hibernate 전용 접근 제어자가 아니라 Java의 일반 접근 규칙 그대로이며, 같은 패키지 코드와 상속한 하위 클래스에서는 접근 가능하고 다른 패키지의 일반 코드에는 빈 Room 생성을 덜 노출하는 설계 의도 표현에 가깝다.

첫 단위 테스트

테스트 클래스는 src/test/java/com/breakground/room/RoomTest.java에, production 코드와 같은 패키지 구조로 뒀다.

생성자는 실제 애플리케이션에서도 Room을 만들 때 쓰는 도메인 코드이므로 src/main/java의 Room 자체에 둔다.

RoomTest는 Spring Context나 PostgreSQL이 필요 없는 순수 단위 테스트다.

JUnit이 @Test 메서드를 실행하고 assertTrue/assertFalse로 기대값을 비교한다.

첫 테스트는 평일 전용 점심방이 월요일 10:00에 열린다는 규칙을 확인했고 통과했다.

같은 방에 월요일 11:30을 넣은 테스트는 assertFalse로 통과해, [open_at, close_at) 경계의 양쪽을 고정했다.

평일 전용 야근방(21:00~02:00)이 금요일 21:00에 열린다는 테스트로 자정 통과 구간의 시작 시각 포함 규칙도 검증했다.

./gradlew test는 RoomTest 같은 순수 단위 테스트와 @SpringBootTest가 붙은 통합 테스트를 같은 작업 안에서 함께 실행한다.

Spring Boot를 bootRun으로 띄우는 것과 테스트를 실행하는 건 서로 다른 Gradle 작업이라, 서버를 켜둔다고 테스트가 자동으로 도는 건 아니다.

나중에는 Controller만 검증하는 @WebMvcTest, Repository만 검증하는 @DataJpaTest처럼 필요한 Spring 부분만 올리는 테스트도 쓸 수 있다.

처음엔 Spring의 구조와 테스트가 번거로운 절차처럼 보였는데, 시간·요일·자정 통과 규칙이 쌓이면서 한 번 실행해보는 것만으로 모든 경우가 계속 맞는지 보장하기 어려워졌다.

테스트는 이미 정한 규칙을 짧은 문장으로 남기고, 이후 변경이 그 규칙을 깨뜨리지 않는지 확인하는 장치로 보이기 시작했다.

남은 것

  • V2~V4 migration과 isOpen() 경계·요일 처리는 완료, 검증했다.
  • GET /rooms가 DTO로 열린 방만 반환하는 것도 실제 데이터로 확인했다.
  • Room 생성자 정리와 첫 단위 테스트까지 했지만, Room.isOpen()의 토요일 새벽 전날 기준 열림, 토요일 밤 주말 닫힘 같은 나머지 경계 케이스는 아직 테스트로 다 덮지 못했다.
  • 네트워크 오류의 근본 원인은 여전히 미확인이고, AnonymousUser와 Message 도메인은 아직 손대지 않았다.