코딩 컨벤션
dictionary-api가 실제로 지키는 코딩 컨벤션이다. 도구가 자동으로 강제하는 규칙과, 코드 작성·리뷰 시 직접
신경 써야 하는 규칙을 구분한다.
코드 포매팅 (spotless, 자동 강제)
Section titled “코드 포매팅 (spotless, 자동 강제)”- Google Java Style Guide를 spotless로 강제한다.
build.gradle spotless {java {targetExclude 'build/generated/querydsl/**' // QClass 생성 파일 제외googleJavaFormat().aosp().formatJavadoc(false) // 구글 자바 포맷 (4칸 들여쓰기)removeUnusedImports()trimTrailingWhitespace()endWithNewline()}} - 명령어
Terminal window ./gradlew spotlessCheck # 포맷 문제 확인(문제 있으면 에러)./gradlew spotlessApply # 포맷 문제 자동 수정 spotlessApply는compileJava(빌드) 시점에 자동 실행된다. 커밋 시점에는 자동 적용되지 않는다 —pre-commit훅이spotlessCheck만 수행해서, 문제가 있으면 커밋을 차단할 뿐이다(자세한 내용은 깃훅 가이드 참고). 이 경우spotlessApply를 직접 실행한 뒤 다시 커밋해야 한다.
final 키워드 명시 (도구가 강제하지 않음, 직접 신경 써야 함)
Section titled “final 키워드 명시 (도구가 강제하지 않음, 직접 신경 써야 함)”- 필드(생성자 주입 대상)/메소드 파라미터/로컬 변수/예외 catch 변수/상수(
static final)는 재할당하지 않는 한 전부final을 명시한다.private final BoardRepository boardRepository; // 생성자 주입 필드public SearchStdictResponseDto findStdict(final String word) { ... } // 파라미터final SessionUser sessionUser = ServletUtils.getSessionUser(); // 로컬 변수} catch (final Exception e) { ... } // 예외 변수 - 재할당이 꼭 필요한 경우에만
final을 생략한다 —final이 없는 변수를 보면 “이 값은 메소드 안에서 바뀐다”는 신호로 바로 읽히게 하려는 의도다. 조건별로 값을 조립하는 로직은 재할당 대신 메소드를 분리하거나 삼항 연산자로 처리해final을 유지한다. - spotless/Google Java Format이 자동으로 강제하는 규칙이 아니다(
final유무를 검사하지 않는다). - 이유: (1) 이미 정착된 관례라 새 코드도 맞춰야 스타일이 갈라지지 않고, (2) 값이 한 번만 세팅된다는 걸 코드만 보고 알 수 있어 가독성이 좋고, (3) 실수로 인한 재할당을 컴파일 타임에 잡아준다.
모던 자바 문법 우선 (권장)
Section titled “모던 자바 문법 우선 (권장)”dictionary-api는 Java 26 툴체인(build.gradle의languageVersion = JavaLanguageVersion.of(26))으로 빌드한다. 같은 동작을 구식 문법과 모던 문법 둘 다로 쓸 수 있으면 모던 문법을 우선으로 쓰는 것을 권장한다.- 현재 코드에서 쓰고 있는 모던 문법은 다음과 같다.
// 패턴 매칭 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) {}
- 새 코드에서도 같은 기준을 따른다. 분기가 값을 돌려주면
switch표현식(case X -> ...)을, 여러 줄 문자열은 텍스트 블록(""")을 쓰는 식이다. - 단, 모던 문법이 기존 규칙과 부딪히면 기존 규칙을 따른다. 예를 들어
var는final과 함께 써야 하고, 타입이 우변에서 바로 드러나지 않으면 쓰지 않는다. 기능별 payload DTO는 기존 코드처럼 Lombok (@Getter/@Setter/@NoArgsConstructor등)을 붙인 클래스로 두고record로 바꾸지 않는다. - 도구가 강제하는 규칙이 아니므로 코드 리뷰에서 확인한다.
제어문은 한 줄이어도 중괄호로 감싼다
Section titled “제어문은 한 줄이어도 중괄호로 감싼다”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);}- 이유: (1) 나중에 본문에 문장을 추가할 때 중괄호를 빠뜨려 두 번째 문장이 조건 밖에서 실행되는 실수를 막는다. (2) 들여쓰기만 보고 블록을 착각하는 일이 없다. (3) Google Java Style Guide의 규칙(4.1.1 Use of optional braces)과 같다.
- spotless(google-java-format)는 중괄호를 추가해 주지 않는다. 들여쓰기만 정리하므로 직접 지켜야 한다.
DTO 상속 구조 — 공통 봉투(envelope)만 상속
Section titled “DTO 상속 구조 — 공통 봉투(envelope)만 상속”- “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 BoardCreateRequestDto/BoardResponseDto같은 기능별 payload DTO는 부모 클래스 없는 순수 POJO다 — 컨트롤러가RequestDto<BoardCreateRequestDto>처럼 제네릭으로 감싸 쓸 뿐이다.- 같은 접두어를 쓰는 payload DTO끼리도 서로 상속하지 않는다. 필드가 완전히 같아도 공통 부모 클래스로 추출해 중복을 없애지 않고 필드 중복을 그대로 허용한다 — 위임 생성자 보일러플레이트 대비 얻는 이득이 적기 때문이다.
- 공통 봉투 클래스 쪽에만 상속을 쓰는 이유: (1)
payload/재변환 헬퍼처럼 실제로 공유하는 구현을 한 곳에만 두기 위해서, (2) 상속을 쓰면 Jackson이 부모 필드를 같은 레벨의 평면(flat) JSON으로 얹어줘서 컴포지션보다 코드가 적게 든다, (3)PaginationRequestDto/ListResponseDto처럼 하위 클래스마다 진짜is-a관계로 새 필드/의미를 추가한다.
Context 클래스 — 중간 표현이 필요할 때
Section titled “Context 클래스 — 중간 표현이 필요할 때”- 기본 흐름: 컨트롤러가
RequestDto의 payload를 서비스에 넘기고, 서비스가 반환한Entity를 컨트롤러가XxxResponseDto.from(entity)로 변환한다 —Entity가 자연스러운 중간 표현 역할을 한다. 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)"] endContext클래스는dto/context/하위 패키지에 별도 파일로 둔다 — RequestDto/ResponseDto가 있는 네임스페이스 클래스 안에 중첩하지 않는다. 컨트롤러↔서비스 사이에서만 쓰이는 내부 전용 객체라 API 계약의 일부가 아니라는 것을 위치로 드러낸다.
응답 DTO는 기능 전용이다
Section titled “응답 DTO는 기능 전용이다”api/<feature>/dto에 있는 응답 DTO는 그 기능 컨트롤러가 클라이언트에게 돌려주는 응답 계약이지, 여러 기능이 공유하는 모델이 아니다.- 다른 기능이 같은 데이터가 필요해지는 시점에도, 그 DTO를 공용 위치로 옮기거나 직접 import해서 재사용하지 않고 자기 이름을 붙인 응답 DTO를 새로 만든다.
- 단, 이 금지는 기능 간(cross-feature) 재사용에만 적용된다 — 한 기능 안의 API끼리(find/findAll/ findByPage/create/update)는 응답 DTO 하나를 그대로 공유한다.
- 이유: 한 기능의 응답 DTO를 다른 기능이 재사용하면, 원래 기능이 자기 화면 요구사항 때문에 필드를 바꿀 때 그 다른 기능의 API 응답까지 의도치 않게 함께 바뀌는 결합이 생긴다.
- 엔티티를 직접 노출하지 않고
from(entity)로 변환하며 감사 컬럼 중 사용자 ID·IP를 빼는 규칙은 REST API 개발 가이드 참고.
서비스의 트랜잭션 규칙
Section titled “서비스의 트랜잭션 규칙”- 조회 메소드는
@Transactional(readOnly = true, rollbackFor = BizException.class), 쓰기 메소드는@Transactional(rollbackFor = BizException.class)를 붙인다. - 서비스 메소드는
try/catch (final Exception e)로 감싸BizException으로 변환해 던진다.rollbackFor = BizException.class는 이 변환된 예외에서도 롤백되게 하려는 설정이다. 자세한 흐름은 예외 처리 가이드 참고. - DB를 거치지 않는 서비스(
SearchService,CodeService,FileService)에는@Transactional을 붙이지 않는다.
Jackson 3(tools.jackson.*)을 쓴다
Section titled “Jackson 3(tools.jackson.*)을 쓴다”- Spring Boot 4는 Jackson 3을 쓴다.
ObjectMapper/TypeReference/JsonNode같은 databind·core 클래스는tools.jackson.*패키지에서 가져온다. - Jackson 2(
com.fasterxml.jackson.databind.*)와는 클래스 계층이 달라 서로 호환되지 않는다. 단,@JsonInclude같은 애너테이션은 Jackson 3에서도com.fasterxml.jackson.annotation을 그대로 쓴다. ObjectMapper는 불변이라 설정은JsonMapper.builder()로 생성할 때 한다.java.time지원은 jackson-databind 3.x에 기본으로 들어 있어 별도 모듈을 등록하지 않는다.- JSON 변환이 필요하면 직접 매퍼를 만들지 말고
common.utils.JsonUtils를 쓴다.
외부 API 호출 — 자동 구성된 RestClient.Builder를 주입받는다
Section titled “외부 API 호출 — 자동 구성된 RestClient.Builder를 주입받는다”- 외부 API 클라이언트용
RestClient빈은RestClient.builder()를 직접 호출하지 않는다. Spring Boot가 메시지 컨버터까지 구성해 둔RestClient.Builder를 주입받아baseUrl만 지정한다.config/StdictConfig.java @Beanpublic RestClient stdictRestClient(final RestClient.Builder builder) {return builder.baseUrl(baseUrl)// text/json 응답을 받기 위한 메시지 컨버터 설정(생략).build();} - API 키 같은 비밀값은 프로퍼티로 받고, 프로파일 설정 파일에는 jasypt
ENC(...)로 암호화해 넣는다.
스프링 빈은 주입받는다
Section titled “스프링 빈은 주입받는다”- 일반 빈은
final필드 + 생성자 주입(@RequiredArgsConstructor)으로 받는다.SpringBeanUtils(ApplicationContextProvider가 보관한 컨텍스트 사용)로 빈을 꺼내 쓰는 방식은 정적 팩토리, 엔티티 리스너처럼 생성자 주입이 불가능한 코드에서만 제한적으로 쓴다.
- 커밋 전에
./gradlew build로 컴파일과 테스트가 통과하는지 확인한다(Git 훅은 컴파일을 검사하지 않는다). AI는.claude/agents/build-validator.md에이전트로 이 검증을 맡길 수 있다. .env,application-secret.yaml같은 비밀 정보 파일은 커밋하지 않는다..gitignore나 권한 설정이 자동으로 막아 주지 않으므로 직접 주의한다. 설정 파일의 비밀값은 jasyptENC(...)로 넣는다.dictionary-app(프런트엔드) 코드는 API 연동 확인 같은 참조용으로만 읽는다. 수정은dictionary-app저장소에서 따로 진행한다.- 다른 개발 규칙은 해당 문서를 따른다.
- Git 훅(포맷·커밋 메시지 검사)과
merge.ff자동 설정: 깃훅 가이드 - 새 API의 테스트 범위: 단위 테스트 가이드
master직접 커밋 금지,--no-ff병합: 브랜치 전략, Git 스킬
- Git 훅(포맷·커밋 메시지 검사)과