Skip to content

코딩 컨벤션

dictionary-api가 실제로 지키는 코딩 컨벤션이다. 도구가 자동으로 강제하는 규칙과, 코드 작성·리뷰 시 직접 신경 써야 하는 규칙을 구분한다.

코드 포매팅 (spotless, 자동 강제)

Section titled “코드 포매팅 (spotless, 자동 강제)”
  1. Google Java Style Guide를 spotless로 강제한다.
    build.gradle
    spotless {
    java {
    targetExclude 'build/generated/querydsl/**' // QClass 생성 파일 제외
    googleJavaFormat().aosp().formatJavadoc(false) // 구글 자바 포맷 (4칸 들여쓰기)
    removeUnusedImports()
    trimTrailingWhitespace()
    endWithNewline()
    }
    }
  2. 명령어
    Terminal window
    ./gradlew spotlessCheck # 포맷 문제 확인(문제 있으면 에러)
    ./gradlew spotlessApply # 포맷 문제 자동 수정
  3. spotlessApply는 compileJava(빌드) 시점에 자동 실행된다. 커밋 시점에는 자동 적용되지 않는다 — pre-commit 훅이 spotlessCheck만 수행해서, 문제가 있으면 커밋을 차단할 뿐이다(자세한 내용은 깃훅 가이드 참고). 이 경우 spotlessApply를 직접 실행한 뒤 다시 커밋해야 한다.

final 키워드 명시 (도구가 강제하지 않음, 직접 신경 써야 함)

Section titled “final 키워드 명시 (도구가 강제하지 않음, 직접 신경 써야 함)”
  1. 필드(생성자 주입 대상)/메소드 파라미터/로컬 변수/예외 catch 변수/상수(static final)는 재할당하지 않는 한 전부 final을 명시한다.
    private final BoardRepository boardRepository; // 생성자 주입 필드
    public SearchStdictResponseDto findStdict(final String word) { ... } // 파라미터
    final SessionUser sessionUser = ServletUtils.getSessionUser(); // 로컬 변수
    } catch (final Exception e) { ... } // 예외 변수
  2. 재할당이 꼭 필요한 경우에만 final을 생략한다 — final이 없는 변수를 보면 “이 값은 메소드 안에서 바뀐다”는 신호로 바로 읽히게 하려는 의도다. 조건별로 값을 조립하는 로직은 재할당 대신 메소드를 분리하거나 삼항 연산자로 처리해 final을 유지한다.
  3. spotless/Google Java Format이 자동으로 강제하는 규칙이 아니다(final 유무를 검사하지 않는다).
  4. 이유: (1) 이미 정착된 관례라 새 코드도 맞춰야 스타일이 갈라지지 않고, (2) 값이 한 번만 세팅된다는 걸 코드만 보고 알 수 있어 가독성이 좋고, (3) 실수로 인한 재할당을 컴파일 타임에 잡아준다.
  1. dictionary-api는 Java 26 툴체인(build.gradle의 languageVersion = JavaLanguageVersion.of(26))으로 빌드한다. 같은 동작을 구식 문법과 모던 문법 둘 다로 쓸 수 있으면 모던 문법을 우선으로 쓰는 것을 권장한다.
  2. 현재 코드에서 쓰고 있는 모던 문법은 다음과 같다.
    // 패턴 매칭 instanceof — 형 검사와 캐스팅을 한 번에 한다
    if (e instanceof BizException biz) {
    messageCd = biz.getCodeApiResult().name();
    }
    // Stream.toList() — collect(Collectors.toList()) 대신 쓴다
    final List<Long> ids = request.getPayload().getIds().stream().distinct().toList();
    // Optional — null 검사 대신 orElseThrow로 조회 실패를 처리한다
    return boardRepository
    .findById(id)
    .orElseThrow(() -> new BizException(CodeApiResult.NOT_FOUND));
    // record — 메소드 안에서만 쓰는 단순 값 묶음(그룹핑 키 등)
    private record GroupKey(String word, String pos) {}
  3. 새 코드에서도 같은 기준을 따른다. 분기가 값을 돌려주면 switch 표현식(case X -> ...)을, 여러 줄 문자열은 텍스트 블록(""")을 쓰는 식이다.
  4. 단, 모던 문법이 기존 규칙과 부딪히면 기존 규칙을 따른다. 예를 들어 var는 final과 함께 써야 하고, 타입이 우변에서 바로 드러나지 않으면 쓰지 않는다. 기능별 payload DTO는 기존 코드처럼 Lombok (@Getter/@Setter/@NoArgsConstructor 등)을 붙인 클래스로 두고 record로 바꾸지 않는다.
  5. 도구가 강제하는 규칙이 아니므로 코드 리뷰에서 확인한다.

제어문은 한 줄이어도 중괄호로 감싼다

Section titled “제어문은 한 줄이어도 중괄호로 감싼다”
  1. if/else/for/while/do-while의 본문이 한 문장뿐이어도 반드시 중괄호({})로 감싼다.
    // 금지
    if (list.isEmpty()) return;
    for (final Long id : ids) repository.deleteById(id);
    // 권장
    if (list.isEmpty()) {
    return;
    }
    for (final Long id : ids) {
    repository.deleteById(id);
    }
  2. 이유: (1) 나중에 본문에 문장을 추가할 때 중괄호를 빠뜨려 두 번째 문장이 조건 밖에서 실행되는 실수를 막는다. (2) 들여쓰기만 보고 블록을 착각하는 일이 없다. (3) Google Java Style Guide의 규칙(4.1.1 Use of optional braces)과 같다.
  3. spotless(google-java-format)는 중괄호를 추가해 주지 않는다. 들여쓰기만 정리하므로 직접 지켜야 한다.

DTO 상속 구조 — 공통 봉투(envelope)만 상속

Section titled “DTO 상속 구조 — 공통 봉투(envelope)만 상속”
  1. “DTO가 부모 클래스를 가진다”는 말은 기능별 payload DTO가 아니라, 요청/응답을 감싸는 공통 봉투 클래스에만 해당한다.
    classDiagram
      direction TB
      class AbstractDto~T~ {
        payload 필드 + 재변환 헬퍼
      }
      class RequestDto~T~
      class PaginationRequestDto~T~
      class ResponseDto~T~
      class ListResponseDto~T~ {
        extends ResponseDto~List~T~~
      }
      class PaginationResponseDto~T~ {
        extends ResponseDto~List~T~~
      }
      AbstractDto <|-- RequestDto
      AbstractDto <|-- ResponseDto
      RequestDto <|-- PaginationRequestDto
      ResponseDto <|-- ListResponseDto
      ResponseDto <|-- PaginationResponseDto
  2. BoardCreateRequestDto/BoardResponseDto 같은 기능별 payload DTO는 부모 클래스 없는 순수 POJO다 — 컨트롤러가 RequestDto<BoardCreateRequestDto>처럼 제네릭으로 감싸 쓸 뿐이다.
  3. 같은 접두어를 쓰는 payload DTO끼리도 서로 상속하지 않는다. 필드가 완전히 같아도 공통 부모 클래스로 추출해 중복을 없애지 않고 필드 중복을 그대로 허용한다 — 위임 생성자 보일러플레이트 대비 얻는 이득이 적기 때문이다.
  4. 공통 봉투 클래스 쪽에만 상속을 쓰는 이유: (1) payload/재변환 헬퍼처럼 실제로 공유하는 구현을 한 곳에만 두기 위해서, (2) 상속을 쓰면 Jackson이 부모 필드를 같은 레벨의 평면(flat) JSON으로 얹어줘서 컴포지션보다 코드가 적게 든다, (3) PaginationRequestDto/ListResponseDto처럼 하위 클래스마다 진짜 is-a 관계로 새 필드/의미를 추가한다.

Context 클래스 — 중간 표현이 필요할 때

Section titled “Context 클래스 — 중간 표현이 필요할 때”
  1. 기본 흐름: 컨트롤러가 RequestDto의 payload를 서비스에 넘기고, 서비스가 반환한 Entity를 컨트롤러가 XxxResponseDto.from(entity)로 변환한다 — Entity가 자연스러운 중간 표현 역할을 한다.
  2. tools처럼 DB를 거치지 않는 순수 계산 기능은 Entity가 없다. 이런 경우에 한해 Context 클래스를 만들어 그 자리를 대신한다(RequestDto.toContext() → 서비스가 Context를 채워 반환 → XxxResponseDto.from(context)).
    flowchart LR
      subgraph basic["기본 흐름"]
        direction LR
        req1["RequestDto.payload"] --> svc1["Service"] --> ent["Entity"] --> res1["XxxResponseDto.from(entity)"]
      end
      subgraph ctx["DB를 거치지 않는 기능 (tools 등)"]
        direction LR
        req2["RequestDto.toContext()"] --> svc2["Service<br/>(Context를 채워 반환)"] --> con["Context"] --> res2["XxxResponseDto.from(context)"]
      end
  3. Context 클래스는 dto/context/ 하위 패키지에 별도 파일로 둔다 — RequestDto/ResponseDto가 있는 네임스페이스 클래스 안에 중첩하지 않는다. 컨트롤러↔서비스 사이에서만 쓰이는 내부 전용 객체라 API 계약의 일부가 아니라는 것을 위치로 드러낸다.
  1. api/<feature>/dto에 있는 응답 DTO는 그 기능 컨트롤러가 클라이언트에게 돌려주는 응답 계약이지, 여러 기능이 공유하는 모델이 아니다.
  2. 다른 기능이 같은 데이터가 필요해지는 시점에도, 그 DTO를 공용 위치로 옮기거나 직접 import해서 재사용하지 않고 자기 이름을 붙인 응답 DTO를 새로 만든다.
  3. 단, 이 금지는 기능 간(cross-feature) 재사용에만 적용된다 — 한 기능 안의 API끼리(find/findAll/ findByPage/create/update)는 응답 DTO 하나를 그대로 공유한다.
  4. 이유: 한 기능의 응답 DTO를 다른 기능이 재사용하면, 원래 기능이 자기 화면 요구사항 때문에 필드를 바꿀 때 그 다른 기능의 API 응답까지 의도치 않게 함께 바뀌는 결합이 생긴다.
  5. 엔티티를 직접 노출하지 않고 from(entity)로 변환하며 감사 컬럼 중 사용자 ID·IP를 빼는 규칙은 REST API 개발 가이드 참고.
  1. 조회 메소드는 @Transactional(readOnly = true, rollbackFor = BizException.class), 쓰기 메소드는 @Transactional(rollbackFor = BizException.class)를 붙인다.
  2. 서비스 메소드는 try/catch (final Exception e)로 감싸 BizException으로 변환해 던진다. rollbackFor = BizException.class는 이 변환된 예외에서도 롤백되게 하려는 설정이다. 자세한 흐름은 예외 처리 가이드 참고.
  3. DB를 거치지 않는 서비스(SearchService, CodeService, FileService)에는 @Transactional을 붙이지 않는다.
  1. Spring Boot 4는 Jackson 3을 쓴다. ObjectMapper/TypeReference/JsonNode 같은 databind·core 클래스는 tools.jackson.* 패키지에서 가져온다.
  2. Jackson 2(com.fasterxml.jackson.databind.*)와는 클래스 계층이 달라 서로 호환되지 않는다. 단, @JsonInclude 같은 애너테이션은 Jackson 3에서도 com.fasterxml.jackson.annotation을 그대로 쓴다.
  3. ObjectMapper는 불변이라 설정은 JsonMapper.builder()로 생성할 때 한다. java.time 지원은 jackson-databind 3.x에 기본으로 들어 있어 별도 모듈을 등록하지 않는다.
  4. JSON 변환이 필요하면 직접 매퍼를 만들지 말고 common.utils.JsonUtils를 쓴다.

외부 API 호출 — 자동 구성된 RestClient.Builder를 주입받는다

Section titled “외부 API 호출 — 자동 구성된 RestClient.Builder를 주입받는다”
  1. 외부 API 클라이언트용 RestClient 빈은 RestClient.builder()를 직접 호출하지 않는다. Spring Boot가 메시지 컨버터까지 구성해 둔 RestClient.Builder를 주입받아 baseUrl만 지정한다.
    config/StdictConfig.java
    @Bean
    public RestClient stdictRestClient(final RestClient.Builder builder) {
    return builder.baseUrl(baseUrl)
    // text/json 응답을 받기 위한 메시지 컨버터 설정(생략)
    .build();
    }
  2. API 키 같은 비밀값은 프로퍼티로 받고, 프로파일 설정 파일에는 jasypt ENC(...)로 암호화해 넣는다.
  1. 일반 빈은 final 필드 + 생성자 주입(@RequiredArgsConstructor)으로 받는다. SpringBeanUtils(ApplicationContextProvider가 보관한 컨텍스트 사용)로 빈을 꺼내 쓰는 방식은 정적 팩토리, 엔티티 리스너처럼 생성자 주입이 불가능한 코드에서만 제한적으로 쓴다.
  1. 커밋 전에 ./gradlew build로 컴파일과 테스트가 통과하는지 확인한다(Git 훅은 컴파일을 검사하지 않는다). AI는 .claude/agents/build-validator.md 에이전트로 이 검증을 맡길 수 있다.
  2. .env, application-secret.yaml 같은 비밀 정보 파일은 커밋하지 않는다. .gitignore나 권한 설정이 자동으로 막아 주지 않으므로 직접 주의한다. 설정 파일의 비밀값은 jasypt ENC(...)로 넣는다.
  3. dictionary-app(프런트엔드) 코드는 API 연동 확인 같은 참조용으로만 읽는다. 수정은 dictionary-app 저장소에서 따로 진행한다.
  4. 다른 개발 규칙은 해당 문서를 따른다.