프론트엔드 개발자로 일하면서 백엔드는 늘 “API 문서 너머의 무언가”였다. axios.post 한 줄을 보내면 응답이 돌아오는데, 그 사이에 무슨 일이 일어나는지는 잘 몰랐다. 그래서 회사에서 쓰는 WMS(창고관리시스템) 백엔드 코드(Java 17 + Spring Boot)를 교재 삼아 공부를 시작했다.

첫 주제는 요청 하나가 들어와서 DB에 저장되기까지의 흐름이다. 예제로는 비교적 단순한 “피처 플래그 등록” API를 골랐다.

글에 나오는 코드는 이해를 돕기 위해 패키지명 등을 단순화했다.


프론트에서 보내는 요청

await axios.post('/api/v2/feature-flag', {
  identifierCode: 'NEW_PICKING',
  identifierName: '신규 피킹',
  description: '신규 피킹 로직',
});

이 요청은 백엔드에서 아래 순서로 지나간다.

[프론트] axios.post
   ↓ HTTP
① Controller   ← 라우터 + 핸들러
   ↓ Request → Command 변환
② UseCase      ← 커스텀 훅 / 서비스 함수
   ↓ Port(인터페이스)를 통해 요청
③ Repository   ← API 클라이언트 (여기서는 DB 클라이언트)
   ↓ SQL
④ MySQL

이 프로젝트는 헥사고날 아키텍처를 따르고 있어서 모듈이 이렇게 나뉘어 있다.

  • api 모듈: Controller
  • usecase 모듈: UseCase, Port(인터페이스)
  • infrastructure 모듈: Repository(DB 접근 구현)
  • domain 모듈: Entity(도메인 모델)

① Controller: 요청을 받는 곳

@RestController                          // 이 클래스는 HTTP 요청을 받는다
@RequestMapping("/api/v2/feature-flag")  // 기본 URL
@RequiredArgsConstructor
public class FeatureFlagController {

    private final RegisterFeatureFlag registerFeatureFlag;  // 사용할 UseCase

    @PostMapping                                         // POST 요청이면 이 메서드 실행
    public AppResponse<Void> register(
        @AuthenticatedUserId Long userId,                // 로그인 사용자 id
        @RequestBody RegisterFeatureFlagRequest request  // body JSON → Java 객체
    ) {
        registerFeatureFlag.execute(
            new RegisterFeatureFlagCommand(
                userId,
                request.identifierCode(),
                request.identifierName(),
                request.description()
            )
        );
        return AppResponse.success();                   // 공통 응답 형태로 감싸서 반환
    }
}

Next.js API Route나 Express의 app.post('/...', handler)와 같은 역할이다.

@로 시작하는 건 어노테이션이다. “이 클래스(메서드)를 이렇게 다뤄줘”라고 Spring에 알려주는 표시로, TS의 데코레이터와 비슷하게 느껴졌다.

Controller에는 비즈니스 로직이 없다. 요청을 받아서 UseCase에 넘기기만 한다. 프론트에서 “UI 컴포넌트에 비즈니스 로직을 넣지 말자”고 하는 것과 같은 이유다.

Request와 Command는 왜 나눌까?

// 프론트가 보내는 JSON 모양 (API 계약)
public record RegisterFeatureFlagRequest(
    String identifierCode,
    String identifierName,
    String description
) {}
// UseCase가 받는 입력
@Getter
public class RegisterFeatureFlagCommand extends SelfValidation<RegisterFeatureFlagCommand> {

    @NotNull private final Long userId;
    @NotNull private final String code;
    @NotNull private final String name;
    private final String description;

    public RegisterFeatureFlagCommand(Long userId, String code, String name, String description) {
        this.userId = userId;
        this.code = code;
        this.name = name;
        this.description = description;
        validateSelf();   // 만들어지는 순간 @NotNull 검증
    }
}
  • Request: 프론트와 맞춘 API 요청 타입. 프론트에서 API 타입을 따로 정의하는 것과 같다.
  • Command: UseCase의 입력. 생성되자마자 스스로 검증한다. zod의 schema.parse()와 비슷하다.

이렇게 나눠 두면 프론트 쪽 필드명(identifierCode)이 바뀌어도 내부 로직(code)은 건드리지 않아도 된다.


② UseCase: 할 일의 순서를 정하는 곳

@UseCase
@RequiredArgsConstructor
public class RegisterFeatureFlag {

    private final LoadFeatureFlag loadFeatureFlag;    // "저장해줘"
    private final ExistFeatureFlag existFeatureFlag;  // "이미 있어?"

    @Transactional   // 이 안의 DB 작업은 전부 성공하거나 전부 취소된다
    public void execute(RegisterFeatureFlagCommand command) {
        FeatureFlag featureFlag = new FeatureFlag(...);   // 도메인 객체 생성
        checkDuplicateFeatureFlag(command.getCode());     // 중복이면 예외
        loadFeatureFlag.loadFeatureFlag(featureFlag);     // 저장
    }

    private void checkDuplicateFeatureFlag(String code) {
        if (existFeatureFlag.existFeatureFlag(code)) {
            throw new AlreadyExistFeatureFlag(code);
        }
    }
}

화면 없이 로직만 있는 커스텀 훅(useRegisterFeatureFlag)이라고 생각하니 이해가 쉬웠다. “만들고 → 중복 확인하고 → 저장한다”는 순서를 지휘하는 역할이다.


Port: 이 구조에서 가장 중요한 개념

public interface ExistFeatureFlag {
    boolean existFeatureFlag(String code);
}

UseCase는 DB를 직접 모른다. “code로 존재 여부를 알려주는 무언가”라는 인터페이스(Port) 만 안다.

프론트로 바꿔 보면 이런 느낌이다.

// 훅은 fetch인지 axios인지 모르고, 함수 시그니처만 안다
function useRegister(api: { exists(code: string): Promise<boolean> }) { ... }

그래서 테스트할 때 가짜 구현(mock)을 넣어 DB 없이 로직만 검증할 수 있다. MSW로 API를 대체하는 것과 같은 원리다.


③ Repository: Port의 실제 구현

@Repository
@RequiredArgsConstructor
public class FeatureFlagRepository implements LoadFeatureFlag, ExistFeatureFlag {
    //                                         ↑ "이 Port들을 내가 구현할게"

    private final FeatureFlagJpaRepository featureFlagJpaRepository;

    @Override
    public boolean existFeatureFlag(String code) {
        return featureFlagJpaRepository.existsByDetailInformationCode(code);
    }
}

신기했던 점은 existsByDetailInformationCode를 아무도 구현하지 않았다는 것이다. JPA가 메서드 이름을 읽고 SELECT ... WHERE code = ? SQL을 자동으로 만들어준다. (JPA는 다음 글들에서 따로 정리할 예정)


④ Entity: 테이블 한 줄 = Java 객체 하나

@Entity
@Table(name = "feature_flag")    // feature_flag 테이블과 연결
public class FeatureFlag {

    @Id
    private Long id;             // PK
    private LocalDateTime deletedAt;
    private Long updatedBy;

    public void delete(Long userId) {
        this.deletedAt = LocalDateTime.now();
        this.updatedBy = userId;
    }

    public void validateModifiable(...) {
        validateNotActive();     // 켜져 있으면 수정/삭제 불가
        ...
    }
}

데이터와 그 데이터를 바꾸는 규칙(메서드)이 한곳에 모여 있다. “켜져 있는 플래그는 지울 수 없다” 같은 규칙이 UseCase가 아니라 Entity 안에 있다. 이게 “도메인 로직은 도메인 모델 안에 둔다”는 원칙이다.


직접 따라가 본 삭제 API

복습 삼아 삭제 API(DELETE /api/v2/feature-flag/{id})를 직접 따라가 봤다.

처음에는 UseCase → Command → Entity.delete 순서라고 생각했는데, 실제로는 Controller가 Command를 만들어서 UseCase에 넘긴다.

Controller.delete
  └─ new DeleteFeatureFlagCommand(userId, featureFlagId)   ← null 검증
  └─ DeleteFeatureFlag.execute(command)
       ├─ ① 권한 확인
       ├─ ② Port로 DB 조회 → 없으면 NotFound 예외
       ├─ ③ featureFlag.validateModifiable(...)  ← 지워도 되는지 확인
       ├─ ④ featureFlag.delete(userId)
       └─ ⑤ 이력 저장
@DeleteMapping("/{featureFlagId}")
public AppResponse<Void> delete(
    HttpSession httpSession,
    @PathVariable Long featureFlagId   // URL의 {featureFlagId} 값 → React Router의 useParams()
) { ... }

실제로 지우기 전에 확인 단계가 세 번 있었다. 프론트는 버튼을 숨기면 되지만, 백엔드는 누가 어떤 요청을 보내든 막아야 하기 때문이다.

놀랐던 점 1: save를 호출하지 않는다

featureFlag.delete(userId);   // 객체 필드만 바꿈
// repository.save(featureFlag) 같은 코드가 없다!

그런데도 DB에 반영된다. JPA의 변경 감지(dirty checking) 덕분이다.

  1. @Transactional 메서드가 시작되면 JPA가 DB에서 꺼낸 객체의 스냅샷을 기억한다.
  2. 메서드 안에서 객체 필드를 바꾼다.
  3. 메서드가 끝날 때 스냅샷과 비교해서 바뀐 부분을 UPDATE SQL로 자동 실행한다.

객체를 직접 수정하면 알아서 반영해주는 MobX/Immer와 비슷하다. 반대로 등록할 때는 새 객체라 JPA가 아직 모르기 때문에 save를 명시적으로 불러야 한다.

놀랐던 점 2: 삭제해도 행이 지워지지 않는다

delete()는 deletedAt과 updatedBy만 채운다. 이런 방식을 soft delete라고 한다. 그럼 목록에 계속 나오지 않을까? 엔티티에 붙은 어노테이션이 이를 막는다.

@SQLRestriction("deleted_at IS NULL")
public class FeatureFlag { ... }

이 엔티티를 조회하는 모든 SQL에 WHERE deleted_at IS NULL이 자동으로 붙는다. 데이터는 이력과 복구용으로 남기고, 애플리케이션에서는 없는 것처럼 보이게 하는 방식이다.

놀랐던 점 3: 켜져 있는 플래그를 지우려고 하면

validateNotActive()에서 예외를 던진다.

public class ActiveFeatureFlagException extends AppRuleException {
    private static final String MESSAGE = "활성화(ON) 상태인 기능 플래그는 삭제하거나 코드를 변경할 수 없습니다.";
    private static final String ERROR_CODE = "feature-flag/active";
    ...
}

예외가 나면 그 뒤 코드는 실행되지 않고, @Transactional 때문에 그때까지 한 DB 작업도 모두 롤백된다. 예외는 공통 예외 처리기에서 에러 응답으로 바뀐다. 프론트에서 보던 에러 토스트 메시지가 바로 여기서 만들어진다.


정리

백엔드 프론트 대응
Controller API Route 핸들러
Request DTO (record) API 요청 타입
Command zod로 검증된 입력
UseCase 커스텀 훅 / 서비스 함수
Port (interface) 주입받는 API 함수 시그니처
Repository API 클라이언트 구현체
Entity 상태 + 상태를 바꾸는 규칙이 붙은 모델

다음 글에서는 오늘 코드를 읽으며 헷갈렸던 Java 문법을 TypeScript와 비교하며 정리하려고 한다.