본문 바로가기
개발진행목록/스터디카페 통합 조회 플랫폼

[studyhub] 모듈러 모놀리식에서의 인터페이스 정의

by o3oppp 2026. 8. 30.

SeatValidator

모듈러 모놀리식을 처음 적용하면서 가장 헷갈렸던 것은 인터페이스를 정의하는 위치, 구현하는 위치, 사용하는 위치였다.

reservation 모듈 ─ 정의: SeatValidator, MemberValidator (reservation.port)
                └ 사용: ReservationService — 인터페이스만 호출

cafe 모듈        ─ 구현: CafeSeatValidatorImpl (cafe.service)
member 모듈      ─ 구현: MemberValidatorImpl (member.service)
  • 정의와 사용은 같은 모듈에 있다. 필요한 것을 선언한 쪽이 그것을 쓴다.
  • 구현만 다른 모듈에 있다. cafe와 member는 자기 도메인 데이터를 써서 답만 해준다.
  • 구현체가 인터페이스보다 하위에 있으니 의존도 아래로 흐를 것 같지만, 실제로는 구현체(아래)에서 인터페이스(위)로 흐른다.
package com.studyhub.reservation.port; // reservation이 필요한 것을 스스로 선언

public interface SeatValidator {

    SeatValidationResult validate(Long cafeId, Long seatId);
}
package com.studyhub.cafe.service; // cafe가 reservation의 요구사항을 구현
...
@Component
@RequiredArgsConstructor
public class SeatValidatorImpl implements SeatValidator {

    private final SeatRepository seatRepository;

    @Override
    public SeatValidationResult validate(Long cafeId, Long seatId) {
        Seat seat = seatRepository.findById(seatId).orElse(null);

        if (seat == null) {
            return SeatValidationResult.NOT_FOUND;
        }
        if (!seat.getCafe().getId().equals(cafeId)) {
            return SeatValidationResult.CAFE_MISMATCH;
        }
        if (seat.getStatus() == SeatStatus.DISABLED) {
            return SeatValidationResult.DISABLED;
        }

        return SeatValidationResult.VALID;
    }
}

reservation이 Validator를 정의하는 이유

  • 존재하지 않는 좌석에 예약이 생성되면 데이터 무결성이 깨지므로 검증이 필요하다.
  • 그런데 모듈 경계 원칙상 reservation은 SeatRepository도 MemberRepository도 직접 참조할 수 없다.
  • 검증은 필요하지만 구현은 직접 할 수 없다는 모순을 인터페이스로 질문만 던지고 답은 해당 도메인이 하도록 하였다.

구현체가 SeatRepository를 참조해도 되는 이유

  • 처음에는 CafeSeatValidatorImplSeatRepository를 참조하는 것이 모듈 간 참조가 아닌지 헷갈렸다.
  • SeatRepository는 cafe 모듈 소속이므로 같은 모듈이다. 모듈 소속은 패키지 최상위 이름으로 판단한다.

연관관계 매핑 가능 여부가 모듈 판단 기준이 아닌 이유

  • Seat와 Cafe가 @ManyToOne을 맺을 수 있는 것은 애초에 두 엔티티를 같은 모듈로 설계했기 때문이다.
  • "연관관계가 있어서 같은 모듈"이 아니라 "같은 모듈로 정했기 때문에 연관관계가 허용"

MemberValidator

package com.studyhub.cafe.port;

public interface OwnerValidator {

    OwnerValidationResult validate(Long memberId);
}
package com.studyhub.reservation.port;

public interface MemberValidator {

    MemberValidationResult validate(Long memberId);
}

MemberValidator 인터페이스를 따로 만든 이유

  • member 모듈은 이미 OwnerValidator를 구현하고 있어서 회원 조회 로직이 중복되는 것처럼 보였다.
  • 하지만 두 인터페이스가 던지는 질문이 다르다. OwnerValidator는 "이 회원이 이 카페의 사장인가", MemberValidator는 "이 회원이 존재하고 활성상태인가"이다.
  • 하나로 합치는 경우 한쪽 요구사항 변경 시 다른 쪽 구현도 영향을 받는다.

실제 중복이 생기는 지점

@Component
@RequiredArgsConstructor
public class OwnerValidatorImpl implements OwnerValidator {

    private final MemberService memberService;

    @Override
    public OwnerValidationResult validate(Long memberId) {
        Member member = memberService.findById(memberId).orElse(null);

        if (member == null) {
            return OwnerValidationResult.NOT_FOUND;
        }
        if (member.getStatus() == MemberStatus.WITHDRAWN) {
            return OwnerValidationResult.WITHDRAWN;
        }
        if (member.getRole() != Role.OWNER) {
            return OwnerValidationResult.NOT_OWNER_ROLE;
        }
        return OwnerValidationResult.VALID;
    }
}
Component
@RequiredArgsConstructor
public class MemberValidatorImpl implements MemberValidator {

    private final MemberRepository memberRepository;

    @Override
    public MemberValidationResult validate(Long memberId) {
        Member member = memberRepository.findById(memberId).orElse(null);

        if (member == null) {
            return MemberValidationResult.NOT_FOUND;
        }
        if (member.getStatus() == MemberStatus.WITHDRAWN) {
            return MemberValidationResult.WITHDRAWN;
        }
        return MemberValidationResult.VALID;
    }
}
  • 두 인터페이스를 구현한 OwnerValidatorImplMemberValidatorImpl은 회원 조회와 탈퇴 확인이 겹친다.
  • 두 구현체 모두 member 모듈 소속이므로 이 중복은 모듈 내부에서 공용 컴포넌트로 뽑아내면 된다.(추후 리팩토링 예정)

구현체 네이밍을 Impl로 통일한 이유

MemberOwnerValidatorImpl   ← 모듈명 + Impl
CafeSeatValidatorImpl      ← 모듈명 + Impl
MemberValidatorImpl        ← Impl
  • 처음에는 구현체가 서로 다른 네이밍 규칙을 사용했다.
  • 모듈명 + Impl로 통일하면 MemberValidator의 구현체가 MemberMemberValidatorImpl이 된다.
  • 패키지 경로에 이미 모듈명이 드러나므로 클래스명에 다시 넣을 필요가 없다고 판단하였다.

CafeInfo

CafeInfo를 reservation.port에 둔 이유

package com.studyhub.reservation.port;
...

@Getter
@RequiredArgsConstructor
public class CafeInfo {

    private final String cafeName;
    private final String seatNumber;

    public static CafeInfo of(String cafeName, String seatNumber) {
        return new CafeInfo(cafeName, seatNumber);
    }
}
  • cafe 모듈에 두면 reservation이 cafe 클래스를 import하게 되어 모듈 경계가 깨진다.

단점

  • Reservation이 Cafe를 연관관계로 맺고 있었다면 join fetch 한 번으로 N+1문제를 해결할 수 있다.
  • ID만 참조하므로 포트로 따로 조회해야 하고, 그 포트를 단건으로 설계하면 반복 호출이 된다.
  • 모듈 경계를 지키는 대신 조인으로 해결되는 것을 애플리케이션에서 조합해야 한다는 단점이 존재한다.