그순간

더모먼트팀의 컨벤션

배재현·2026년 7월 2일·100 조회
더모먼트팀의 컨벤션

들어가며

효과적인 협업은 단순한 기술적 역량을 넘어, 팀 전체가 공유하는 언어와 구조에서 비롯됩니다. 컨벤션은 바로 그 공유된 언어입니다. 일관된 코드 스타일은 가독성을 높이고, 유지보수 비용을 낮추며, 새로운 팀원이 컨텍스트를 빠르게 흡수할 수 있는 환경을 만들어줍니다.
결국 컨벤션은 커뮤니케이션 오버헤드를 줄이고, 팀이 본질적인 문제 해결에 집중할 수 있도록 돕는 토대입니다.

더모먼트팀의 컨벤션

더모먼트팀은 빠르게 변화하는 개발 환경 속에서도 지속적인 개선을 추구하며, 팀 스스로 컨벤션을 정의하고 함께 발전시켜 왔습니다. 이러한 더모먼트팀의 컨벤션을 이번 기술 블로그에 자세히 작성해보았습니다.

커밋 메시지, 브랜치 이름 그리고 Pull Request

Git의 커밋 메시지와 브랜치 이름을 통해 어떤 작업이 수행되었는지, 한 눈에 파악할 수 있도록 돕습니다.

커밋 메시지

커밋 메시지는 타입(작업 도메인): 변경 사항 형식을 따릅니다.
여기서 타입은 아래와 같이 커밋의 목적을 표현합니다.

  • feature 또는 feat : 새로운 기능 추가 (ex. CRUD 기능 추가)
  • refactor : 기존의 코드 리팩토링 작업 (ex. 중복 함수 제거)
  • chore : 빌드 프로세스 또는 기타 설정 변경 (ex. ci/cd 파이프라인 구축)
  • fix : 버그 수정
  • test : 테스트 코드 수정
  • docs : 문서 수정

작업 도메인은 작업을 진행한 도메인의 명칭입니다. 예를 들면 oauth, board, admin이 존재합니다. 단 예외적으로 한 커밋에 여러 도메인을 작업하였다면 global을 사용합니다.
그리고 변경 사항은 본문과 꼬리말 없이 변경 사항을 한 줄로 요약하여 작성합니다. 이때, ~기능 구현 형식으로 작업의 변경 사항이 잘 드러나도록 작성합니다.

틀린 커밋 메시지 예시 : feat(board): 게시글 기능 구현했습니다, refactor(global): 코드 수정
옳은 커밋 메시지 예시 : feat(board): 게시글 기능 구현, fix(oauth): 로그인 리다이렉트 설정 수정, refactor(global): 중복 메서드를 헬퍼 메서드로 추출

브랜치 이름

브랜치 이름은 main, master, develop을 제외하고 타입/작업 사항 형식을 따릅니다.

  • main 또는 master : 서비스를 릴리즈하는 브랜치입니다.
  • develop : 추가적인 개발 시 베이스가 되는 브랜치로, 릴리즈로 머지하기 전 개발 환경을 배포하는 브랜치입니다. 모든 개발 브랜치는 릴리즈가 아닌 develop으로 머지합니다.
  • hotfix/<작업 사항> : 릴리즈 환경에서 발생한 이슈를 신속하게 해결하기 위한 브랜치로, Pull Request를 통해 즉시 main으로 머지 후 develop 브랜치로 머지합니다.

나머지 브랜치 이름은 커밋 메시지와 타입의 형식은 동일합니다.

  • feature 또는 feat/작업 사항 : 새로운 기능 추가
  • refactor/작업 사항 : 기존의 코드 리팩토링 작업
  • chore/작업 사항 : 빌드 프로세스 또는 기타 설정 변경
  • fix/작업 사항 : 버그 수정
  • test/작업 사항 : 테스트 코드 수정
  • docs/작업 사항 : 문서 수정

작업 사항은 해당 브랜치에서 작업한 내용을 영어 한 줄로 요약하여 작성하고, 공백은 하이픈(-)을 사용하여 구분합니다.

틀린 예시 : upload board, 비동기 사용으로 수정
옳은 예시 : feat/upload-board, hotfix/use-async

PR 전략

PR 대상 브랜치는 develop으로 지정합니다. (main 브랜치가 아닙니다).
PR 제목은 [작업 도메인] 작업 내용 형식을 따릅니다. 이때 작업 도메인은 앞서 설명했던 커밋 메시지에 사용했던 작업 도메인과 일치합니다.
서비스 내 고유한 무언가를 지칭하는 단어는 백틱(`)을 사용합니다.

예시 : [oauth] OAuth 로그인 기능 구현, [global] 전역 예외 핸들러 설정 수정

서비스 릴리즈

서비스 릴리즈를 위한 개념과 릴리즈 상황에서 발생하는 문제에 대한 대처 방법을 정리하여, 온보딩을 마친 팀원들에게 실수로 인한 리스크를 줄이도록 합니다.

개발 환경과 상용 환경

더모먼트팀에서는 각 프로젝트마다 개발 환경(stage), 상용 환경(prod)이 존재합니다.

상용 환경은 서비스를 사용자에게 배포하는 릴리즈와 같습니다.
main 또는 master 브랜치로 머지된다면 ci/cd를 통해 자동으로 상용 환경으로 배포가 진행됩니다.
이때 릴리즈가 진행되었지만 데이터베이스 마이그레이션이 누락, 환경변수 미설정 등의 이유로 애플리케이션 구동에 실패하여 서비스가 다운된 경우, 즉시 이전 버전으로 롤백을 진행하고 팀원들에게 전달해야 합니다. 이후 트러블슈팅 과정을 통해 문제를 해결 후 다시 릴리즈를 진행합니다.

개발 환경은 상용 환경을 통해 릴리즈를 진행하기 전, 테스트를 진행하기 위한 환경입니다. 릴리즈를 통해 사용자에게 배포하기 전에, 오류를 꼼꼼히 분석하고 해결하여 완벽한 상태로 릴리즈하기 위함입니다.
develop 브랜치로 머지된다면 ci/cd를 통해 자동으로 개발 환경으로 배포가 진행됩니다.

릴리즈 버전

서비스 릴리즈 시 버전은 v연도 4자리 + 월 2자리 + 일 2자리.릴리즈 횟수 형태로 통일합니다.

예시로 2026년 6월 24일에 릴리즈를 진행하였다면 v20260624.0이 버전이 됩니다.
그리고 동일한 날에 릴리즈를 한번 더 진행하였다면 맨 뒤 숫자가 올라가 v20260624.1이 됩니다.

develop -> main으로 머지하는 PR(릴리즈 PR) 작성 시 PR 제목은 릴리즈의 버전이여야 합니다.
예시 : PR #159 / v20260426.0, PR #160 / v20260426.1

서버(Backend)는 릴리즈 PR을 올리기 전 build.gradle에서 버전을 수정하여 develop 브랜치에 적용합니다. 이미지

백엔드의 컨벤션 (프론트엔드는 아래에 있습니다!)

1. 패키지 / API 구조

도메인 기반 패키지 구조는 domain/{도메인 명}/{controller, service, repository, dto ...}로 통일됩니다. (아래 사진 참고)

이미지

도메인 공통 설정(config, security, 예외 등)은 global/ 패키지에 위치합니다. (아래 사진 참고)

이미지

API 경로는 /{도메인 명}/{버전}/{리소스 명}[/{id}] 패턴을 따릅니다. 버전 번호는 프로젝트마다 다르므로 각 프로젝트 문서를 참고합니다. 이때 경로는 소문자에 kebab-case로 작성하고, 컬렉션 리소스는 복수형 명사를 사용합니다.

GET  /oneseo/v3/oneseo/{memberId}
GET  /oneseo/v3/oneseo/search?page=0&size=10
POST /oneseo/v3/oneseo/me

2. Controller / API 컨벤션

Query parameter가 1~2개이고 단순하면 @RequestParam을 사용하고, 3개 이상이거나 검증이 필요하면 @ModelAttribute + DTO를 사용합니다. 그리고 @RequestBody는 항상 @Valid와 함께 사용합니다.

// 1~2개 -> @RequestParam
@GetMapping("/scopes")
fun getScopes(@RequestParam role: AccountRole): ApiScopeListResDto

// 3개 이상 -> @ModelAttribute + DTO
@GetMapping("/students")
fun getStudents(@Valid @ModelAttribute queryReq: QueryStudentReqDto): StudentListResDto

@Transactional은 메서드 레벨에만 선언합니다. 클래스 레벨 선언은 금지됩니다.
조회 로직에는 @Transactional(readOnly = true)를, 쓰기 로직에는 @Transactional을 사용합니다.

// 옳은 예시
@Transactional(readOnly = true)
override fun execute(queryReq: QueryStudentReqDto): StudentListResDto { ... }

// 틀린 예시 : 클래스 레벨 선언 금지
@Service
@Transactional
class StudentServiceImpl { ... }

응답에는 엔티티를 직접 노출하지 않고, 항상 DTO로 변환해서 반환합니다. Swagger/OpenAPI 어노테이션은 신규 엔드포인트에 필수로 작성하며, 설명은 한국어로 작성합니다. Controller에서 Service로는 DTO를 그대로 전달하고, @PathVariable 값은 개별로 전달할 수 있습니다.

@PutMapping("/{id}")
fun updateStudent(@PathVariable id: Long, @Valid @RequestBody reqDto: UpdateStudentReqDto): StudentResDto =
    updateStudentService.execute(id, reqDto)

3. DTO 규칙

  • Request DTO와 Response DTO는 접미사(~ReqDto / ~ResDto)로 구분합니다. (아래 사진 참고)
이미지

DTO는 불변 객체로 작성합니다 (Kotlin은 val 필드, Java는 record를 사용합니다). 응답 DTO에는 Setter를 노출하지 않습니다.

// Kotlin : val 필드
data class StudentResDto(
    val studentId: Long,
    val studentName: String
)
// Java : record
public record FoundMemberResDto(
    Long id,
    String name
) { }

4. 예외 처리

커스텀 예외 클래스는 ExpectedException 하나만 사용합니다. 서브클래싱은 절대 금지됩니다.

예외 메시지는 한국어로 작성합니다. 이때 사용자에게 토스트/알림 등으로 그대로 노출될 수 있으므로 주의합니다.

new RuntimeException(...)을 직접 사용하지 않고, 항상 ExpectedException을 사용합니다.

Controller에는 try/catch를 작성하지 않고, 전역 예외 핸들러(@RestControllerAdvice)로 처리를 위임합니다.

빈 catch 블록으로 예외를 삼키지 않습니다.

val student = studentRepository.findById(id).orElseThrow {
    ExpectedException("학생을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)
}

5. 로깅 규칙

System.out.println / println()은 사용하지 않고, SLF4J Logger 라이브러리만 사용합니다.

파라미터화된 로깅({} 플레이스홀더)을 사용하고, 문자열 결합이나 인터폴레이션으로 값을 삽입하지 않습니다. (예: logger.info("Deleted {} items", count))

비밀번호, 토큰, 주민번호 등 민감 정보는 로그에 남기지 않습니다.

로그 메시지 언어(영어/한국어)는 프로젝트별로 다르므로 각 프로젝트 문서를 참고합니다.

// 옳은 예시
logger().info("Deleted {} expired API keys", deletedCount)

// 틀린 예시 : 문자열 인터폴레이션 / 결합
logger().error("에러 발생: $message")
logger().error("Failed to process: " + message)

6. 테스트 규칙

JUnit 5와 Mockito를 기반으로 하고, BDD 스타일 스터빙(given().willReturn())을 사용합니다.

서비스(비즈니스 로직) 코드를 기준(source of truth)으로 삼습니다. 서비스가 올바르게 변경되어 테스트가 깨지면 테스트를 수정합니다.

단위 테스트에는 @SpringBootTest 대신 @Mock + @InjectMocks를 사용합니다. Thread.sleep()으로 타이밍을 맞추는 테스트는 작성하지 않습니다.

@ExtendWith(MockitoExtension.class)
class CreateMemberServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @InjectMocks
    private CreateMemberService service;

    @Test
    void it_throws_expected_exception() {
        given(memberRepository.existsById(1L)).willReturn(false);

        assertThrows(ExpectedException.class, () -> service.execute(1L));
    }
}

7. 코드 스타일

불변성을 우선시하며, 재할당이 꼭 필요한 경우가 아니면 불변 변수와 불변 객체를 사용합니다.

의존성 주입은 생성자 주입만 사용합니다. 필드 주입(@Autowired lateinit var, 필드에 @Autowired)은 금지됩니다.

불필요한 주석은 지양하고, 로직이 자명하지 않을 때만 주석을 작성합니다.

Null 안전성 문법을 사용하고, Kotlin 언어에서 강제 언랩(!! 등)은 지양합니다.

// 옳은 예시 : 생성자 주입 + val
@Service
class StudentService(
    private val studentRepository: StudentJpaRepository
) {
    val name = student?.name ?: "Unknown"
}

// 틀린 예시 : 필드 주입 + 강제 언랩
@Service
class StudentService {
    @Autowired
    lateinit var studentRepository: StudentJpaRepository

    val name = student!!.name
}

프론트엔드의 컨벤션 (React + JavaScript 기준)

FSD 아키텍처 등 러닝커브가 있는 내용은 제외하고, React + JavaScript 개발에 바로 필요한 필수 컨벤션만 정리했습니다.

1. 파일/폴더 네이밍

구분 네이밍 규칙 예시
컴포넌트 폴더 PascalCase Header/, ExampleList/
컴포넌트 파일 index.jsx ExampleList/index.jsx
유틸리티/훅 파일 camelCase useDebounce.js, utils.js
상수 파일 camelCase cookies.js, navigation.js
에셋 파일 PascalCase Logo.jsx, EmptyState.jsx

참고 학습 자료
JavaScript 네이밍 컨벤션 가이드
camelCase vs PascalCase 설명

2. 배럴 익스포트(Barrel Export)

각 폴더에 index.js를 두고 export * from 또는 export { default as ... } 형태로 내보냅니다. 폴더 밖에서는 개별 파일 경로가 아니라 index.js를 통해서만 import합니다.

// widgets/example/index.js
export { default as ExampleList } from './ui/ExampleList';
export { default as ExampleFilter } from './ui/ExampleFilter';
// 사용하는 쪽
import { ExampleList, ExampleFilter } from '@/widgets/example';

학습 자료
JavaScript 모듈 시스템 이해하기
배럴 익스포트 패턴 가이드

3. 컴포넌트 컨벤션

컴포넌트는 기본적으로 화살표 함수(Arrow Function) 로 작성하고, default export로 내보냅니다. 그리고 props는 구조 분해 할당으로 전달받습니다.
변수/훅으로 가져온 값은 컴포넌트 상단에 위치합니다. 핸들러 함수 및 기타 로직은 "변수 선언"과 useEffect 사이에 위치합니다. useEffect는 return 바로 위에 위치합니다.

const ExamplePage = ({ data, isLoading }) => {
  // 1. 변수/훅 선언
  const [isOpen, setIsOpen] = useState(false);
  const { data: fetchedData } = useGetExample();

  // 2. 핸들러 함수 및 기타 로직
  const handleClick = () => {
    setIsOpen(true);
  };

  // 3. useEffect
  useEffect(() => {
    // ...
  }, []);

  // 4. return
  return <div>...</div>;
};

export default ExamplePage;

학습 자료
JavaScript 구조 분해 할당 기본
React Props 구조 분해 할당 패턴

4. 스타일링 컨벤션

Tailwind CSS를 사용하고, clsx + tailwind-merge 기반의 cn() 유틸리티를 사용합니다. 클래스명은 가능한 한 하나의 문자열로 관리합니다.
버튼 등 다양한 변형이 있는 컴포넌트는 CVA(class-variance-authority) 사용을 권장합니다.

// cn() 기본 사용
className={cn('flex gap-2 items-center')}

// 조건부 클래스
className={cn('flex gap-2', isActive && 'bg-primary')}

// 여러 조건 조합
className={cn('flex gap-2 items-center', isActive && 'bg-primary', isDisabled && 'opacity-50')}

학습 자료
Tailwind CSS 공식 문서
CVA (class-variance-authority) 문서

5. Prettier

코드 포맷팅은 팀 공용 .prettierrc 설정을 그대로 사용합니다. 개인 설정으로 임의로 덮어쓰지 않습니다.
에디터(VSCode 등)에 Prettier 확장을 설치하고 저장 시 자동 포맷(Format on Save)을 켜두면 편합니다. 커밋 전에 포맷이 깨지지 않았는지 한 번 더 확인합니다.

학습 자료
Prettier 공식 문서
VSCode에서 Prettier 자동 포맷 설정하기

마치며

지금까지 살펴본 커밋/브랜치 전략, 릴리즈 프로세서, 백엔드와 프론트엔드 컨벤션은 모두 하나의 목적을 향합니다.
팀원 누구나 코드를 보고 "왜 이렇게 작성했는지"를 묻지 않아도 되는 환경을 만드는 것입니다.

컨벤션은 한 번 정해지면 끝나는 규칙이 아니라, 팀이 성장하면서 함께 다듬어가는 약속입니다.

더모먼트팀도 지금의 컨벤션에 안주하지 않고, 더 나은 협업 방식을 계속 고민하고 있습니다. 이 글이 새로 합류하는 팀원들의 온보딩 시간을 줄이고, 프로젝트를 더 빠르게 이해하는 데 도움이 되길 바랍니다.

목차