[3~4주차] 팀장으로서의 성장과 10개 이슈를 쳐낸 한 주 - 1

코헤·2026년 2월 3일

cohiChat

목록 보기
4/10

들어가며

4주차는 "막막함"이 "할 일 목록"으로 바뀌는 전환점이었다. 3주차까지는 "뭘 해야 하지?"라는 막연함이 있었는데, 이번 주는 Issue를 잘 쓰고, PR을 잘 쓰고, 코드리뷰를 잘 활용하면서 체계적으로 일을 쳐내는 방법을 체득했다.

이번 회고는 크게 세 파트로 나눈다:
1. 기술적 성장: 예약 도메인, 보안, 파일 업로드, Google Calendar 연동
2. 팀장으로서의 인사이트: 협업 방식 개선, Issue/PR 템플릿, 범위 설정
3. 회고와 다음 단계

Part 1: 기술적 성장

1-1. 예약(Booking) 도메인 설계

이번 주 가장 많은 시간을 쏟은 도메인이다. 총 6개 이슈(#57~#62)를 처리하면서 CRUD 전체 사이클을 완성했다.

상태 관리 설계

예약의 상태 전이를 명확히 정의하는 것이 핵심이었다

SCHEDULED → ATTENDED (호스트가 출석 처리)
SCHEDULED → NO_SHOW (호스트가 노쇼 처리)
SCHEDULED → LATE (호스트가 지각 처리)
SCHEDULED → CANCELLED (게스트가 사전 취소)
SCHEDULED → SAME_DAY_CANCEL (게스트가 당일 취소)

여기서 "누가 어떤 상태로 바꿀 수 있는가"를 명확히 하는 것이 중요했다

  • 호스트: 출석 상태 변경 (ATTENDED, NO_SHOW, LATE)
  • 게스트: 취소만 가능 (CANCELLED, SAME_DAY_CANCEL)

이 구분이 없으면 게스트가 자기 예약을 "출석 완료"로 바꿔버리는 황당한 상황이 발생한다.

UniqueConstraint 버그와 해결

예약 생성 시 @UniqueConstraint(columnNames = {"time_slot_id", "booking_date"})를 걸었는데, 취소된 예약도 차단하는 문제가 발생했다.

문제 상황:
1. A가 1월 20일 10시 예약 → SCHEDULED
2. A가 취소 → CANCELLED
3. B가 1월 20일 10시 예약 시도 → DB constraint violation!

해결: DB 레벨 제약 제거, 애플리케이션 레벨에서 "활성 예약만" 중복 체크

// 취소된 예약은 제외하고 중복 검사
@Query("SELECT COUNT(b) > 0 FROM Booking b " +
       "WHERE b.timeSlot = :timeSlot " +
       "AND b.bookingDate = :bookingDate " +
       "AND b.attendanceStatus NOT IN :excludedStatuses")
boolean existsActiveBooking(...);

배운 점: DB 제약조건은 "절대 위반되면 안 되는 규칙"에만 사용하고, 비즈니스 로직이 복잡하면 애플리케이션 레벨에서 처리하는 게 유연하다.


1-2. 보안: IDOR 취약점과 동시성 제어

IDOR (Insecure Direct Object Reference)

코드리뷰에서 발견된 Critical 이슈였다.

IDOR 보안 취약점 - 권한 검증 누락이 있다는 내용이었다

특정 엔드포인트에서 인증된 사용자가 리소스 소유자인지 검증하지 않았다 이게 왜 문제냐면...인증된 사용자가 다른 사용자의 예약 정보를 조회 가능 하기 때문이다 (OWASP Top 10 - Broken Access Control 에 소개되어 있다)

@AuthenticationPrincipal로 인증 사용자 받아서 소유권 검증 로직 추가했다.

문제 코드:

@GetMapping("/{bookingId}")
public BookingResponse getBooking(@PathVariable Long bookingId) {
    return bookingService.getBookingById(bookingId);
    // 인증된 사용자라면 아무 예약이나 조회 가능!
}

해결 코드:

@GetMapping("/{bookingId}")
public BookingResponse getBooking(
    @PathVariable Long bookingId,
    @AuthenticationPrincipal UserPrincipal user) {

    Booking booking = bookingRepository.findById(bookingId)
        .orElseThrow(() -> new CustomException(BOOKING_NOT_FOUND));

    boolean isGuest = booking.getGuestId().equals(user.getId());
    boolean isHost = booking.getTimeSlot().getUserId().equals(user.getId());

    if (!isGuest && !isHost) {
        throw new CustomException(ACCESS_DENIED);
    }
    return BookingResponseDTO.from(booking);
}

배운 점: "인증(Authentication)"과 "인가(Authorization)"는 다르다. 로그인했다고 모든 리소스에 접근할 수 있는 게 아니다.

동시성 제어

두 사용자가 동시에 같은 슬롯을 예약하면?

Thread A: 중복 체크 → 없음 ✓
Thread B: 중복 체크 → 없음 ✓
Thread A: 저장 ✓
Thread B: 저장 ✓ (중복 예약 발생!)

해결: 비관적 락(Pessimistic Lock) 적용

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM TimeSlot t WHERE t.id = :id")
Optional<TimeSlot> findByIdWithLock(@Param("id") Long id);

1-3. Query Method vs @Query 기술적 의사결정

중복 예약 검사 시 자신을 제외해야 하는 로직이 필요했다.

Before (Query Method):

boolean existsByTimeSlotAndBookingDateAndAttendanceStatusNotInAndIdNot(
    TimeSlot timeSlot,
    LocalDate bookingDate,
    List<AttendanceStatus> excludedStatuses,
    Long excludedBookingId
);
// 메서드명이 60자가 넘어감...

메서드명이 60자 넘어가니 코드 리뷰에서 동기식 설명이 필요할 때마다 숨이 차올랐다.
내가 무슨 독일어 공부를 하는 것도 아니고 다른 방식이 있나 고민하게 되었다.

After (@Query):

@Query("SELECT COUNT(b) > 0 FROM Booking b " +
       "WHERE b.timeSlot = :timeSlot " +
       "AND b.bookingDate = :bookingDate " +
       "AND b.attendanceStatus NOT IN :excludedStatuses " +
       "AND b.id <> :excludedId")
boolean existsDuplicateBookingExcludingSelf(...);

판단 기준:

  • 조건 1-2개 → Query Method
  • 조건 3개 이상 → @Query
  • 메서드명 7단어 이상 → 무조건 @Query

Spring Data JPA 공식 문서에서도 "메서드명이 길어지면 @Query를 쓰라"고 권장한다.

"The mechanism to create queries from method names is handy, but it can lead to very long method names. In these cases, you can use the @Query annotation."

고려했지만 선택하지 않은 대안

Querydsl: 동적 쿼리가 많거나 복잡한 검색 조건이 필요할 때 도입하는 라이브러리. 현재 상황(메서드명이 길다)에서는 오버엔지니어링. @Query로 5분이면 해결되는 문제에 새 라이브러리 도입은 불필요.


1-4. 파일 업로드 구현

예약에 파일을 첨부할 수 있는 기능을 구현했다.

핵심 설계:
1. FileStorageService 인터페이스 분리 (추후 S3 전환 대비)
2. LocalFileStorageService 구현 (현재는 로컬 디스크)
3. DB 저장 실패 시 디스크 파일 롤백

롤백 로직:

public BookingFileResponse uploadFile(Long bookingId, MultipartFile file, UUID userId) {
    String storedFileName = fileStorageService.store(file);

    try {
        BookingFile bookingFile = BookingFile.builder()
            .booking(booking)
            .fileName(storedFileName)
            .build();
        bookingFileRepository.save(bookingFile);
        return BookingFileResponse.from(bookingFile);
    } catch (Exception e) {
        fileStorageService.delete(storedFileName); // 롤백!
        throw e;
    }
}

1-5. Google Calendar 연동

예약 생성 시 Google Calendar에 자동으로 이벤트를 생성하는 기능을 구현했다.

Service Account 방식 선택:

  • OAuth2는 사용자별 토큰 관리가 복잡
  • Service Account는 서버-서버 통신으로 단순

비동기 처리 고려사항:

  • Google API 실패해도 예약은 정상 진행되어야 함
  • 추후 @Async 적용 예정

1-6. FE-BE 마이그레이션 (Python → Spring Boot)

백엔드를 FastAPI에서 Spring Boot로 전환하면서 프론트엔드 API 호출을 수정했다.

변경 포인트:
| 항목 | Before (Python) | After (Spring Boot) |
|------|-----------------|---------------------|
| Base URL | localhost:8000 | localhost:8080/api |
| 로그인 | /account/login | /members/v1/login |
| 회원가입 | /account/signup | /members/v1/signup |
| 필드명 | snake_case | camelCase |

11개 파일의 fallback URL을 수정하고, .env 파일로 환경변수를 분리했다.


마치며

4주차는 양적 성장질적 성장이 동시에 일어난 주였다.

10개 이슈를 처리하면서

  • UniqueConstraint 버그 → DB 제약 vs 비즈니스 로직의 경계 학습
  • 60자 메서드명 → Query Method vs @Query 판단 기준 확립

기술적으로는 성장했지만, 속도에 취한 건 아닌지 경계해야 한다.
이번 주 코드리뷰에서 발견된 보안 이슈가 증명하듯, 빠르게 쳐내는 것보다 제대로 쳐내는 것이 더 중요할 것 같다..

완료한 이슈 목록

Issue number제목도메인
[[57]]예약 생성Booking
[[58]]예약 조회 APIBooking
[[59]]호스트 예약 수정Booking
[[60]]게스트 예약 수정Booking
[[61]]예약 상태 변경/취소Booking
[[62]]예약 파일 업로드Booking/File
[[63]]Google Calendar 연동Calendar
[[76]]FE 환경 변수 설정Frontend
[[78]]로그인 API 마이그레이션Frontend
[[79]]회원가입 API 마이그레이션Frontend
profile
하이하이

0개의 댓글