본 포스팅은 팀 프로젝트를 경험하면서 컨벤션 부재로 고생한 뒤, "처음부터 이걸 알았더라면" 하는 마음으로 정리한 내용이다.
컨벤션(Convention) 이란, 팀원 전원이 동의한 "코드 작성 방식과 협업 규칙의 집합"이다.
단순히 들여쓰기를 탭으로 할지 스페이스로 할지 같은 포맷 이야기가 아니다.
어디에 파일을 두고, 어떤 방식으로 커밋을 남기고, 예외를 어떻게 처리하고, 누가 언제 merge를 승인하는지까지 포함된다.
이상적인 목표는 "코드베이스 전체가 한 사람이 작성한 것처럼 일관성을 지니는 것"이다.
실제로 컨벤션이 없으면 어떤 일이 벌어지는지 정리해보면 아래와 같다.
| 문제 상황 | 원인 |
|---|---|
| 팀원마다 API 응답 구조가 다르다 | 공통 응답 포맷 미합의 |
| PR마다 공백 변경 diff가 넘친다 | 코드 포맷터 미통일 |
| Git 히스토리를 보기 어렵다 | 커밋 메시지 규칙 없음 |
| 탈퇴 유저가 조회 결과에 포함된다 | Soft Delete 처리 방식 불일치 |
| 같은 에러인데 응답 형태가 다르다 | ErrorCode 미공유 |
... 등등 수 없이 많은 문제와 비효율이 일어난다.
이 문제들은 개인 역량 문제가 아니다. 처음에 약속이 없었기 때문에 생기는 구조적 문제다.
이렇듯 팀 컨벤션 설정과 합의는 코스트를 절약한다는 측면에서도 매우 중요하며, 필수적으로 수반되어야하는 과정이다.
컨벤션은 크게 세 영역으로 나눌 수 있다.
코드 스타일 → 네이밍, 포맷, 어노테이션 사용 방식 등 ...
아키텍처 결정 → 패키지 구조, BaseEntity, 공통 응답, DB 설계 등 ...
개발 프로세스 → Git 전략, PR 규칙, 이슈 관리, 배포 전략 등...
처음 프로젝트를 시작할 때, 많은 팀이 코드 스타일만 약속하고 아키텍처 결정과 프로세스는 넘어가는 경향이 있다. 그러나 실제 개발 환경에서는 아키텍처 결정이 더 큰 파급력을 가진다. 한 번 정해진 패키지 구조나 DB 테이블명은 나중에 바꾸면 전체 코드와 마이그레이션에 영향을 준다.
좋은 팀 컨벤션에는 공통적인 특성이 있다.
코드 한 줄 쓰기 전에 공통 기반(ApiResponse, ErrorCode, 패키지 구조, DB prefix)을 먼저 확정해야 한다. 중간에 바꾸면 이미 작업한 팀원 코드를 전부 수정해야 한다.
컨벤션은 위에서 내려오는 규칙이 아니다. 팀원 전원이 논의하고 동의한 규칙이어야 실제로 지켜진다. 일방적으로 정해진 컨벤션은 누군가가 반드시 무시하게 된다.
구두 약속은 컨벤션이 아니다. Notion, README, Wiki 등 팀원 누구나 언제든 확인할 수 있는 곳에 정리되어야 한다.
"왜 이렇게 해야 하는가"를 함께 적어두면 컨벤션의 설득력이 생긴다. 이유를 모르면 형식적으로 따르거나 다음 프로젝트에서 다시 똑같은 논쟁이 반복된다.
컨벤션은 언제까지나 '효율'을 위함임을 잊지 말자.
지킬 수 없는 규칙은 없는 것만 못하다. .editorconfig, Checkstyle, PR 템플릿, CI 파이프라인 등으로 자동화할 수 있는 것들은 도구로 강제하는 것이 좋다.
아래는 5~6인 규모의 팀 프로젝트 기준으로 실제 개발 환경에서 중요하게 다뤄지는 항목들을 우선순위 순으로 정리한 것이다.
ApiResponse)모든 팀원이 의존하는 공통 기반이다. 중간에 바꾸면 이미 작업한 코드 전체를 수정해야 한다.
// 성공 응답
{
"status": 200,
"message": "success",
"data": { ... }
}
// 에러 응답 (동일한 포맷으로 통일)
{
"status": 400,
"code": "INVALID_INPUT",
"message": "아이디는 4자 이상이어야 합니다."
}
ErrorCode enum 공유GlobalExceptionHandler와 ErrorCode 구조는 1순위로 팀 전체에 공유해야 한다. 팀원마다 에러 응답 형태가 달라지는 가장 흔한 원인이 이것이다.
// 합의 예시 — 각 도메인별로 팀원이 추가하는 방식으로 운영
USER_NOT_FOUND(404, "USER_NOT_FOUND", "존재하지 않는 사용자입니다."),
ORDER_NOT_FOUND(404, "ORDER_NOT_FOUND", "존재하지 않는 주문입니다.")
com.example.project
├── domain
│ ├── user
│ │ ├── controller
│ │ ├── service
│ │ ├── repository
│ │ ├── entity
│ │ └── dto
│ │ ├── request # SignupRequest, LoginRequest
│ │ └── response # UserResponse, LoginResponse
│ └── order/ store/ ...
└── global
├── exception
├── response # ApiResponse, ErrorCode
├── security
└── config
global/과 domain/의 경계를 명확히 하지 않으면 팀원마다 파일 위치가 달라진다.
DDL 수준의 결정이기 때문에 나중에 바꾸면 마이그레이션이 필요하다. 반드시 초반에 확정해야 한다.
테이블명 : p_ 접두사 + snake_case → p_user, p_order
PK : 도메인명_id → user_id, order_id
FK : 참조테이블_id → store_id, user_id
Boolean : is_ 접두사 → is_deleted, is_hidden
JPA 설정과도 연동이 필요하다.
# application.yml
spring:
jpa:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
// @Table(name = "p_user")처럼 명시적으로 달 것인지,
// 자동 변환 방식을 쓸 것인지도 통일한다
@Entity
@Table(name = "p_user")
public class User extends BaseEntity { ... }
BaseEntity 구조팀원 전체가 Auditing 등이 필요해서 각 엔티티에 extends BaseEntity를 쓰는 경우에, 구조가 중간에 바뀌면 전체 엔티티가 영향을 받는다.
// 합의할 항목 목록
// - createdAt / updatedAt / deletedAt 포함 여부
// - createdBy / updatedBy: BIGINT(userId) vs String(username) 방식 선택
// - @MappedSuperclass vs @Embeddable 선택
// - AuditorAware 구현체 → userId 기준 vs username 기준
// - deletedAt null = 활성, not null = 삭제 방식으로 통일
클래스 : PascalCase → UserService, OrderRepository
메서드/변수 : camelCase → findById, accessToken
상수 : UPPER_SNAKE_CASE → MAX_PAGE_SIZE, JWT_SECRET
패키지 : 소문자 단수 → com.example.project.user
DTO 네이밍도 규칙을 잡아두면 혼선이 줄어든다.
// 요청 DTO: 기능명 + Request
SignupRequest, LoginRequest, UpdateUserRequest
// 응답 DTO: 기능명 + Response
SignupResponse, LoginResponse, UserResponse
@Transactional 설정 방식합의 없이 가면 팀원마다 위치와 옵션이 제각각이 된다.
@Transactional // 클래스 레벨에 쓰기 트랜잭션을 걸고
public class UserService {
@Transactional(readOnly = true) // 조회 메서드에만 readOnly 오버라이드
public UserResponse getUser(Long userId) { ... }
// 쓰기 메서드는 클래스 레벨 @Transactional 상속
public UserResponse updateUser(...) { ... }
}
// readOnly = true : flush 생략 + 스냅샷 미생성 → 성능 이점
// 반드시 조회 메서드에는 readOnly를 붙이는 것으로 통일
어노테이션 선택 기준과 에러 응답 처리 방식을 통일하지 않으면 팀원마다 결과가 달라진다.
// 어노테이션 선택 기준 정리
@NotNull → null만 방어 (비권장, String에 쓰면 빈 문자열 허용)
@NotBlank → null + 빈 문자열 + 공백 방어 (String 필드 표준)
@NotEmpty → null + 빈 문자열 방어
// MethodArgumentNotValidException 처리는 GlobalExceptionHandler에서 단일 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException e) { ... }
한 방식으로 통일하지 않으면 누군가는 탈퇴 유저를 조회 결과에 포함시키게 된다.
// 방식 A: @SQLRestriction 어노테이션 (Spring Boot 3.x, 구버전은 @Where)
@SQLRestriction("deleted_at IS NULL")
public class User extends BaseEntity { ... }
// 방식 B: 쿼리에 직접 조건 명시
Optional<User> findByIdAndDeletedAtIsNull(Long id);
// 방식 C: QueryDSL where 조건
.where(user.deletedAt.isNull())
// 세 방식 중 하나를 선택해 전원 통일
참고: Spring Boot 3.x부터는 기존 Hibernate의
@Where가 deprecated되고@SQLRestriction으로 대체되었다. 레거시 코드베이스라면@Where도 여전히 동작하지만, 신규 프로젝트라면@SQLRestriction사용을 권장한다.
이것을 명시하지 않으면 누군가는 반드시 Controller에서 Entity를 직접 반환한다.
// 절대 금지
@GetMapping("/{userId}")
public User getUser(@PathVariable Long userId) { // Entity 직접 반환
return userService.getUser(userId);
}
// 올바른 방법
@GetMapping("/{userId}")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long userId) {
return ResponseEntity.ok(ApiResponse.success(userService.getUser(userId)));
}
// 이유:
// 1. password 같은 민감 필드 노출 위험
// 2. 양방향 관계 → 무한 순환 직렬화 (StackOverflowError)
// 3. API 스펙과 DB 구조가 결합됨
main → 배포 브랜치 (직접 push 금지)
develop → 통합 브랜치 (PR로만 merge)
feature/xxx → 기능 개발
fix/xxx → 버그 수정
hotfix/xxx → 긴급 수정
// 브랜치명에 이슈 번호를 포함시키면 추적이 쉬워진다
feature/12-user-signup
fix/15-jwt-nullpointer
<타입>: <요약> (#이슈번호)
Feat: 회원가입 API 구현 (#12)
Fix: JWT 필터 NPE 수정 (#15)
Refactor: UserService 메서드 분리 (#18)
Docs: API 명세서 추가 (#20)
Chore: 의존성 버전 업데이트 (#21)
- PR 단위 : 기능 1개 단위 (너무 크면 리뷰 불가)
- 리뷰어 : 최소 1명 Approve 후 merge
- self-merge : 금지
- CI 실패 상태에서 merge 금지
- .github/pull_request_template.md 미리 등록
// Issue 1개 = 기능 1개 단위
// 브랜치 생성 전에 Issue 먼저 발급
// PR 본문에 "Closes #12" 작성 → merge 시 Issue 자동 close
// 커밋 메시지에 이슈 번호 포함 → GitHub이 커밋과 이슈를 자동 연결
포맷터 통일이 안 되면 공백·탭 차이로 불필요한 diff가 PR마다 발생한다.
# .editorconfig — 프로젝트 루트에 추가
root = true
[*.java]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
IntelliJ 사용자라면 .idea/codeStyles/ 디렉토리를 git에 포함시키는 방법도 있다.
서버, DB, JVM 시간대를 전부 통일하지 않으면 createdAt이 팀원마다 다르게 찍힌다.
# application.yml
spring:
jpa:
properties:
hibernate:
jdbc:
time_zone: Asia/Seoul
# JVM 시작 옵션
-Duser.timezone=Asia/Seoul
# DB 설정도 동일하게 맞춘다
# .env.example 파일을 git에 올려두면 신규 팀원 온보딩이 빠르다
DB_HOST=localhost
DB_PORT=5432
REDIS_HOST=localhost
JWT_SECRET=your_secret_key_here
JWT_ACCESS_EXPIRATION=1800000
JWT_REFRESH_EXPIRATION=604800000
.env 파일 자체는 .gitignore에 포함하고, .env.example만 올리는 것이 원칙이다.
최소 기준을 컨벤션으로 정하지 않으면 시간이 지날수록 테스트 없는 레거시가 쌓인다.
// 테스트 범위 기준
// Unit Test → Service, 핵심 비즈니스 로직
// Integration → Repository (@DataJpaTest)
// Controller → 선택 (MockMvc or @SpringBootTest)
// 테스트 클래스 네이밍: 대상클래스명 + Test
UserServiceTest, UserRepositoryTest
// 메서드 네이밍: 한글 메서드명 or given_when_then
@Test
void 회원가입_성공() { }
@Test
void 회원가입_중복_username_예외() { }
// given / when / then 주석 구조 통일
@Test
void 회원가입_성공() {
// given
SignupRequest request = new SignupRequest("park1234", "password1234!");
// when
SignupResponse response = userService.signup(request);
// then
assertThat(response.getUsername()).isEqualTo("park1234");
}
기술적 컨벤션은 아니지만 협업 품질에 가장 큰 영향을 미친다.
리뷰 응답 기한 : PR 생성 후 24시간 이내
코멘트 레이블 기준:
[P1] must fix → 버그, 보안 이슈, 스펙 불일치
[P2] suggest → 성능, 가독성 개선 제안
[P3] nit → 사소한 스타일, 취향 차이
Approve 기준 : P1 없음 + 전체적으로 이해 가능한 코드
merge 금지 : Approve 없음 / CI 실패 / 충돌 미해결
logging:
level:
root: INFO
com.example.project: DEBUG # 개발 환경만
org.hibernate.SQL: DEBUG # SQL 출력 (개발 환경만)
System.out.println으로 디버깅하면 운영 환경에 그대로 로그가 남는다. 금지 규칙을 명시해야 한다.
// Repository 반환값은 Optional로 통일
Optional<User> findByUsername(String username);
// Service에서 처리 방식 통일
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
// 빈 컬렉션은 null 대신 빈 리스트 반환
return Collections.emptyList(); // null 반환 금지
// 금지: 의도를 바로 파악하기 어려운 숫자를 코드에 직접 삽입
if (size != 10 && size != 30 && size != 50) { ... }
// 권장: 상수 클래스에 모아서 관리
public class PageConstants {
public static final int DEFAULT_PAGE_SIZE = 10;
public static final List<Integer> ALLOWED_PAGE_SIZES = List.of(10, 30, 50);
}
Swagger는 프론트엔드 또는 협업 팀이 API를 파악하는 주요 창구다. 누구는 상세하게 쓰고 누구는 아예 안 쓰면 문서로서의 가치가 없어진다.
// springdoc-openapi 기준 (Spring Boot 3.x 표준)
// springfox는 Spring Boot 2.6 이후 호환 문제로 사실상 사용 지양
// 1) Controller 레벨 — @Tag로 그룹명 명시
@Tag(name = "User", description = "회원 관련 API")
@RestController
@RequestMapping("/v1/users")
public class UserController { ... }
// 2) 각 API 엔드포인트 — @Operation으로 요약 + 설명
@Operation(
summary = "회원가입",
description = "이메일과 비밀번호로 신규 회원을 등록한다."
)
@PostMapping("/signup")
public ResponseEntity<ApiResponse> signup(...) { ... }
// 3) 에러 응답 명시 — @ApiResponses로 가능한 응답 코드 기술
@ApiResponses({
@ApiResponse(responseCode = "200", description = "회원가입 성공"),
@ApiResponse(responseCode = "400", description = "입력값 유효성 오류"),
@ApiResponse(responseCode = "409", description = "중복 username")
})
// 4) DTO 필드 — @Schema로 예시값과 설명 추가
public class SignupRequest {
@Schema(description = "사용자 아이디 (4~20자)", example = "park1234")
@NotBlank
private String username;
@Schema(description = "비밀번호 (8자 이상, 특수문자 포함)", example = "password1!")
@NotBlank
private String password;
}
팀 컨벤션으로 합의할 최소 기준은 아래와 같다.
@Tag : Controller마다 반드시 작성
@Operation : summary는 필수, description은 선택
@ApiResponses: 에러 응답 코드 명시 (최소 성공 / 주요 에러 케이스)
@Schema : DTO 필드에 example 값 필수 작성
민감 정보 : password 등 보안 필드는 @Schema(accessMode = READ_ONLY) 또는 제외
배포 브랜치 : main → 실제 배포 대상
배포 트리거 : main에 merge 시 GitHub Actions 자동 배포
환경 분리 : application-local.yml / application-prod.yml
배포 담당자 : Git 관리자가 main merge 및 배포 최종 확인
롤백 기준 : 배포 후 장애 시 이전 태그로 rollback
# 배포 시 태그를 남기는 습관
git tag v1.0.0
컨벤션을 어디에, 어떤 구조로 작성하면 좋은지도 미리 합의해두는 것이 좋다.
# [프로젝트명] 팀 컨벤션
## 1. 코드 스타일
- 네이밍 규칙
- 패키지 구조
- 어노테이션 사용 기준
## 2. 아키텍처 결정
- 공통 응답 / 에러 구조
- BaseEntity 설계
- DB 네이밍 규칙
## 3. Git 협업
- 브랜치 전략
- 커밋 메시지 규칙
- PR 규칙
## 4. 개발 환경
- 환경변수 설정 (.env.example)
- 포맷터 설정
- 로컬 실행 방법
## 5. 테스트 / 품질
- 테스트 작성 기준
- 코드 리뷰 문화
...
Notion이나 프로젝트 README.md에 이 구조로 작성해두면 신규 팀원 온보딩도 빠르다.
위에서 정리한 내용은 신규 프로젝트 기준이다. 이미 기반이 구축된 레거시 코드베이스에서는 사정이 다르다.
기존 코드에 맞춰 컨벤션을 역으로 정리하는 경우가 많고, 일관성 없는 코드 위에 새 규칙을 얹는 것은 오히려 혼선을 만들 수 있다. 이런 경우에는 "신규 코드부터 적용" 하는 방식이 현실적이다.
또한 @Where → @SQLRestriction 전환처럼 버전 업그레이드에 따라 deprecated된 API가 생기는 경우도 많다. 레거시 환경이라면 무조건 최신 방식으로 바꾸는 것이 목적이 아니라, 팀 내에서 어떤 방식을 쓰고 있는지 명확히 문서화하는 것이 먼저다.
지금까지 내용이 많아 보이지만, 핵심은 단순하다.
코드 한 줄 쓰기 전에 30분~1시간을 투자해서, 팀 전원이 동의한 규칙을 문서화한다.
이 한 번의 투자가 이후 수십 시간의 "왜 이렇게 했어요?" 를 없애준다. 처음부터 완벽할 필요는 없다. 개발을 진행하면서 필요한 항목을 추가하고, 프로젝트가 끝난 뒤 회고에서 다듬어 나가는 것이 더 현실적이고 건강한 접근이다.
컨벤션은 규칙이 아니라 팀의 공통 언어다.
| 순위 | 항목 | 이유 |
|---|---|---|
| 1 | ApiResponse / ErrorCode 구조 | 모든 팀원이 의존하는 공통 기반 |
| 2 | 패키지 구조 | 중간에 바꾸면 전체 import 수정 |
| 3 | DB 테이블 / 컬럼 네이밍 | 나중에 바꾸면 마이그레이션 필요 |
| 4 | BaseEntity 설계 | 전체 엔티티에 영향 |
| 5 | DTO 네이밍 / 유효성 검사 방식 | 협업 혼선 방지 |
| 6 | 브랜치 / 커밋 규칙 | Git 히스토리 관리 |
| 7 | 엔티티 직접 반환 금지 | 보안 이슈로 직결 |
| 8 | Soft Delete 방식 통일 | 데이터 조회 버그 방지 |
| 9 | 포맷터 / Timezone 통일 | 개발 환경 통일 |
| 10 | 테스트 작성 기준 / 코드 리뷰 문화 | 장기 품질 유지 |