들어가며
최근 몇 달 동안 진행한 세 가지 프로젝트를 회고 차원에서 정리해봤다.
•
MoodBook: 감정 기반 도서 추천 플랫폼
•
DungeonTalk: 실시간 TRPG 게임 플랫폼
•
Auth 프로젝트: 회원가입/로그인 인증 시스템
•
아무리 AI를 잘 활용한다 할지라도 100% 프로젝트 요구사항을 만족시키기란 불가능하다.
•
어느 정도 도메인 지식과 개발 경험이 갖춰지지 않으면 코어 개발자로 성장하기 어렵다.
•
AI를 활용하는 팀원들의 성향을 고려하여 몇 가지 규칙을 세워서 프로젝트를 진행하였다.
•
회고를 위해 프로젝트를 진행하면서 적용한 규칙과 그에 대한 사례와 예제를 정리해봤다.
번호 | 규칙 |
1 | 전역 예외 처리(@ControllerAdvice) + 의미 있는 도메인 예외 |
2 | 공통 API 응답객체 필요 |
3 | 비즈니스 로직은 서비스 계층에만 작성 (컨트롤러 계층에는 작성하지 않음) |
4 | DTO 객체 활용하여 데이터 전달하기 |
5 | 캐싱을 도입하여 성능 개선에 대한 케이스 만들 수 있어야 함 |
6 | JWT 토큰 활용하여 기본적인 회원가입/로그인 기능 구현 (RefreshToken은 Redis에 저장) |
7 | 인증 관련 보안 수준을 어느 정도 고려할 수 있는 형태로 구현 (시큐어 코딩) |
8 | 전반적인 DB 설계나 개발 로직은 대규모 트래픽을 충분히 대비할 수 있는 형태로 개발 |
9 | 모니터링 + 부하테스트 할 수 있는 방향으로 개발 |
10 | LocalDateTime이 아닌 Instant 클래스로 적용 (확장성 고려) |
11 | record 쓰지 않고 dto로 작성 |
12 | @Setter 어노테이션은 지양 |
13 | 효율적인 디자인 패턴 적용 (생성, 구조, 행위 패턴) |
14 | JUnit 5 + Mockito 단위테스트 작성 |
15 | 가급적 kotlin 코드는 절대 나타나지 않도록 |
16 | 코드 작성 시 패키지 경로를 상세하게 나타내기 |
17 | 상황마다 적용하기 좋은 알고리즘 반영 |
18 | entity(dao)는 절대로 매개변수로 사용하지 말 것 |
19 | JPA N+1 문제 발생하지 않은 코드 작성 |
20 | 성능 최적화를 고려한 코드 작성 |
21 | 도메인별 서비스 분리 없이 한 클래스에 몰아넣지 않기 |
22 | 기본 LAZY, 조회 전용은 fetch join/EntityGraph/전용 쿼리 DTO 활용 |
23 | 엔티티를 응답으로 직렬화 |
24 | Service 메서드 단위 @Transactional (조회 전용은 readOnly=true) 로 작성 |
25 | 프로파일링 → 핫스팟 파악 → 캐시 전략(TTL/키/무효화) 설계 후 적용 |
26 | 요청/응답/도메인 DTO 분리, 검증은 요청 DTO에 @Valid |
27 | 조건 기반 페이징/필터 쿼리 설계, 인덱스 점검 |
28 | 단위(서비스) 우선 + 슬라이스(@DataJpaTest, @WebMvcTest) 병행 |
29 | Testcontainers/Embedded 활용, 시크릿은 테스트용 별도 |
30 | 무상태 서버, 확장 고려한 코드 작성 |
31 | 로그·메트릭·트레이스 구성 |
32 | Access는 클라이언트, Refresh는 서버측 저장(DB/Redis) + 회전 |
33 | 환경 변수/Secret Manager 사용, @ConfigurationProperties로 주입 |
34 | 외부 I/O는 트랜잭션 밖에서 수행하거나 SAGA/Outbox 패턴 |
35 | 낙관적 락 + 재시도를 기본, 필요한 곳만 비관적 락 |
36 | 패키지 의존 방향을 한쪽으로 고정 (도메인→애플리케이션→인프라) |
37 | 필요한 Origin/Method만 화이트리스트 |
38 | 마스킹/필터링 적용, 요청/응답 바디 로깅은 샘플링 |
39 | var 형식은 지양 |
40 | 코드를 작성함에 있어 충분히 이해할 수 있도록 주석 반영 |
41 | @Getter, @Builder 어노테이션 활용. Setter 사용은 지양 |
42 | Request/Response DTO 코딩 스타일 일관 유지 (from/toEntity 구분) |
43 | DTO에도 @Builder 적용, entity → dto 변환 적극 활용 |
44 | Service 계층은 인터페이스로 만들지 않음 |
45 | 패키지명 전체를 그대로 변수/클래스명으로 사용하지 않음 |
46 | yml, build.gradle 확실하게 작성 |
47 | entity 작성 시 상황에 따라 AccessLevel.PRIVATE/PROTECTED 반영 |
48 | JPA dirty-checking 적극 반영 |
49 | 부모-자식 PK-FK 관계 삭제 연계 처리 (연관된 객체 삭제 보장) |
50 | BaseTime을 적용해 생성/수정 시간 관리 |
51 | BaseTime이나 엔티티에 protected 키워드 사용 지양 |
52 | 자료형별 발생 가능한 예외 고려 |
53 | 글로벌 예외 처리와 공통 API 응답 객체 코드 스타일 일관성 유지 |
54 | REST API 규칙에 맞는 주소 형식 사용 |
55 | Spring Boot 3.5.x 기준, @MockitoBean 활용 테스트 작성 |
56 | 로그인/회원가입 시 @AuthenticationPrincipal 적용 |
57 | 로그인/회원가입 시 CustomUserDetail 적용 |
58 | 가독성이 좋은 코드 작성 |
적용한 규칙에 대한 정리
규칙 1 - 전역 예외 처리(@ControllerAdvice)
설명: 전역 예외 처리를 한 곳에서 처리하여 try-catch를 제거하고, 예외를 의미 있는 메시지로 변환한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ApiResponse<?>> handleDomainException(DomainException e) {
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
return ResponseEntity.internalServerError().body(ApiResponse.fail("예상치 못한 오류 발생"));
}
}
Java
복사
규칙 2 - 공통 API 응답 객체 필요
설명: 모든 API 응답을 일관된 형태로 관리하여 유지보수성을 높인다.
@Getter
@Builder
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return ApiResponse.<T>builder()
.success(true)
.message("success")
.data(data)
.build();
}
public static <T> ApiResponse<T> fail(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.build();
}
}
Java
복사
규칙 3 - 비즈니스 로직은 서비스 계층에만 작성
설명: 컨트롤러는 요청/응답 DTO 매핑만 담당하고, 비즈니스 로직은 서비스 계층에서만 작성한다.
// ❌ 나쁜 예시
@PostMapping("/books")
public ResponseEntity<ApiResponse<BookResponse>> createBook(@RequestBody BookRequest request) {
Book book = new Book(request.getTitle(), request.getAuthor());
bookRepository.save(book);
return ResponseEntity.ok(ApiResponse.ok(BookResponse.fromEntity(book)));
}
// ✅ 좋은 예시
@PostMapping("/books")
public ResponseEntity<ApiResponse<BookResponse>> createBook(@RequestBody BookRequest request) {
return ResponseEntity.ok(ApiResponse.ok(bookService.createBook(request)));
}
Java
복사
규칙 4 - DTO 객체 활용하여 데이터 전달하기
설명: 엔티티를 직접 노출하지 않고 DTO를 통해 데이터 전달.
@Getter
@Builder
public class BookResponse {
private String title;
private String author;
public static BookResponse fromEntity(Book book) {
return BookResponse.builder()
.title(book.getTitle())
.author(book.getAuthor())
.build();
}
}
Java
복사
규칙 5 - 캐싱 도입으로 성능 개선
@Service
@RequiredArgsConstructor
public class BookCacheService {
private final RedisTemplate<String, Object> redisTemplate;
public void cacheBook(String key, BookResponse book) {
redisTemplate.opsForValue().set(key, book, 10, TimeUnit.MINUTES);
}
public BookResponse getBook(String key) {
return (BookResponse) redisTemplate.opsForValue().get(key);
}
}
Java
복사
규칙 6 — JWT 토큰 기반 회원가입/로그인 (RefreshToken은 Redis 저장)
설명: AccessToken은 클라이언트, RefreshToken은 Redis에 TTL 적용.
public AuthResponse login(AuthRequest request) {
Member member = memberRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new AuthException("존재하지 않는 회원이다"));
// 비밀번호 체크
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new AuthException("비밀번호가 일치하지 않는다");
}
String accessToken = jwtTokenProvider.createAccessToken(member.getId().toString());
String refreshToken = jwtTokenProvider.createRefreshToken();
redisTemplate.opsForValue().set("RT:" + member.getId(), refreshToken, 7, TimeUnit.DAYS);
return new AuthResponse(accessToken, refreshToken);
}
Java
복사
규칙 7 - 인증 관련 보안 수준 고려 (시큐어 코딩)
설명: 비밀번호는 평문 저장 금지, JWT Secret은 환경 변수로 관리.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
Java
복사
규칙 8 - 대규모 트래픽 대비 DB 설계
설명: 인덱스 설계, 파티셔닝 고려, 읽기/쓰기 분리.
CREATE INDEX idx_books_title ON books(title);
SQL
복사
규칙 10 - LocalDateTime 대신 Instant 사용
설명: 타임존 이슈 없이 전 세계 어디서든 동일한 시간 기록 가능.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BaseTime {
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
}
Java
복사
규칙 11 - record 쓰지 않고 DTO 작성
설명: record가 편하긴 하지만, 확장성과 @Builder, @Valid 적용을 고려하면 DTO는 class로 작성하는 것이 좋다.
@Getter
@NoArgsConstructor
public class BookRequest {
@NotBlank(message = "제목은 필수 값이다")
private String title;
@NotBlank(message = "저자는 필수 값이다")
private String author;
@Builder
public BookRequest(String title, String author) {
this.title = title;
this.author = author;
}
}
Java
복사
규칙 12 - @Setter 어노테이션 지양
설명: 불변성을 지켜 엔티티/DTO가 예측 불가능하게 변경되지 않도록 하였다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberResponse {
private String username;
private String nickName;
}
Java
복사
규칙 14 - JUnit5 + Mockito 단위 테스트 작성
설명: 서비스 단위의 독립적인 테스트로 안정성을 확보한다.
@ExtendWith(MockitoExtension.class)
class ChatMessageServiceTest {
@Mock
ChatMessageRepository chatMessageRepository;
@Mock
MemberRepository memberRepository;
@Mock
RedisPublisher redisPublisher;
@Mock
ObjectMapper objectMapper;
@Mock
ChatRoomService chatRoomService;
@InjectMocks
ChatMessageService chatMessageService;
@Test
@DisplayName("TALK: 저장 후 Redis로 브로드캐스트하고 DTO 반환")
void process_TALK_publishAndReturn() throws Exception {
// given
ChatMessageSendRequestDto dto = talkReq("room-1", "u-1", "hello");
Member sender = mock(Member.class);
given(sender.getNickName()).willReturn("Neo");
given(memberRepository.findById("u-1")).willReturn(Optional.of(sender));
// save 호출 시 저장된 엔티티 그대로 반환
given(chatMessageRepository.save(any(ChatMessage.class)))
.willAnswer(inv -> inv.getArgument(0));
given(objectMapper.writeValueAsString(any(ChatMessageDto.class)))
.willReturn("{json}");
// when
ChatMessageDto result = chatMessageService.processMessage(dto);
// then
assertThat(result).isNotNull();
assertThat(result.getSenderNickname()).isEqualTo("Neo");
then(redisPublisher).should().publish(eq("room-1"), eq("{json}"));
then(chatRoomService).should(never()).joinRoom(anyString(), anyString());
then(chatRoomService).should(never()).leaveRoom(anyString(), anyString());
}
}
Java
복사
규칙 15 - Kotlin 코드는 절대 나타나지 않도록
설명: 팀 내 기술 스택 일관성을 위해 Kotlin 금지.
// ✅ Java 기반 DTO
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatRequest {
private String roomId;
private String message;
}
Java
복사
규칙 18 - entity(DAO)는 매개변수로 사용하지 않기
설명: 요청/응답은 DTO로만 처리하고, 엔티티는 서비스/리포지토리 내부에서만 다룬다.
// ❌ 나쁜 예시
public void createBook(Book book) { bookRepository.save(book); }
// ✅ 좋은 예시
public void createBook(BookRequest request) {
Book book = Book.builder()
.title(request.getTitle())
.author(request.getAuthor())
.build();
bookRepository.save(book);
}
Java
복사
규칙 19 - JPA N+1 문제 발생하지 않도록 코드 작성
설명: fetch join, EntityGraph, DTO Projection, QueryDSL 활용.
•
사례
// Book과 Author를 fetch join
@Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id")
Optional<Book> findBookWithAuthor(@Param("id") Long id);
// Projection DTO 활용
@Query("""
SELECT new org.com.moodbook.book.dto.BookResponse(
b.id, b.isbn13, b.title, b.author, b.publisher, b.pubDate,
b.reputation, b.coverImage, b.description, b.categoryName,
b.createdAt, coalesce(bc.viewCount, 0)
)
FROM Book b
LEFT JOIN BookCount bc ON b.id = bc.book.id
ORDER BY b.pubDate DESC
""")
Page<BookResponse> findAllByCreatedAt(Pageable pageable);
/* N + 1 문제 처리 */
@EntityGraph(attributePaths = "bookCount")
List<Book> findByIsbn13In(Collection<String> isbn13);
/* N + 1 문제 처리 */
@Query("SELECT b FROM Book b LEFT JOIN FETCH b.bookCount")
List<Book> findAllBooks();
Java
복사
규칙 20 - 성능 최적화를 고려한 코드 작성
설명: 쿼리 최적화, 캐시, 적절한 자료구조 사용.
// HashMap 활용하여 사용자별 도서 추천 캐싱
private final Map<String, List<Book>> userRecommendCache = new HashMap<>();
public List<Book> getUserRecommendations(String userId) {
return userRecommendCache.computeIfAbsent(userId, id -> loadRecommendations(id));
}
Java
복사
규칙 21 - 도메인별 서비스 분리 (한 클래스에 몰아넣지 않기)
설명: 여러 도메인의 로직을 한 서비스에 몰아넣지 않고, 각 도메인마다 서비스를 분리한다.
// ❌ 나쁜 예시
@Service
public class CommonService {
public void createBook(BookRequest request) { /* ... */ }
public void login(AuthRequest request) { /* ... */ }
public void sendChat(ChatRequest request) { /* ... */ }
}
// ✅ 좋은 예시
@Service
public class BookService { /* 도서 관련 로직 */ }
@Service
public class AuthService { /* 인증 관련 로직 */ }
@Service
public class ChatService { /* 채팅 관련 로직 */ }
Java
복사
규칙 22 - 기본 LAZY, 조회 전용은 fetch join/EntityGraph/DTO 활용
설명: 연관 관계는 기본 LAZY로 두고, 조회 전용 시 fetch join을 활용한다.
@Entity
public class Book {
@ManyToOne(fetch = FetchType.LAZY)
private Author author;
}
@Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id")
Optional<Book> findBookWithAuthor(@Param("id") Long id);
Java
복사
규칙 23 - 엔티티를 응답으로 직렬화
설명: DTO 변환을 거친 뒤 JSON 직렬화를 수행한다.
@Getter
@Builder
public class BookResponse {
private String title;
private String author;
public static BookResponse fromEntity(Book book) {
return BookResponse.builder()
.title(book.getTitle())
.author(book.getAuthor().getName())
.build();
}
}
Java
복사
규칙 24 - Service 메서드 단위 @Transactional(readOnly 적용)
설명: 각 서비스 메서드에 명확히 트랜잭션을 지정하고, 조회 전용에는 readOnly=true 를 적용한다.
@Service
@RequiredArgsConstructor
public class BookService {
private final BookRepository bookRepository;
@Transactional(readOnly = true)
public BookResponse getBook(Long id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new NotFoundException("존재하지 않는 책"));
return BookResponse.fromEntity(book);
}
}
Java
복사
규칙 26 - 요청/응답/도메인 DTO 분리, 요청 DTO는 @Valid
설명: 요청/응답/도메인 DTO를 명확하게 나누고, 요청 DTO에는 검증 어노테이션을 적용한다.
@Getter
@NoArgsConstructor
public class AuthRequest {
@NotBlank(message = "아이디는 필수 값이다")
private String username;
@NotBlank(message = "비밀번호는 필수 값이다")
private String password;
}
Java
복사
규칙 30 - 무상태 서버, 확장 고려
설명: 세션 대신 JWT + Redis, 파일은 S3 등 외부 저장소 활용.
// 무상태 인증 (Spring Security Filter)
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
Java
복사
규칙 32 - Access는 클라이언트, Refresh는 서버 쪽에 저장(DB/Redis) + 회전
설명: 클라이언트는 AccessToken만 보관하고, RefreshToken은 서버에서 Redis/DB에 저장해 보안성을 강화한다.
redisTemplate.opsForValue()
.set("RT:" + member.getId(), refreshToken, 7, TimeUnit.DAYS);
Java
복사
규칙 33 - 환경 변수/Secret Manager 사용, @ConfigurationProperties 주입
설명: 비밀번호, 시크릿 키는 .yml 에 직접 쓰지 않고 환경 변수나 Secret Manager로 관리한다.
@ConfigurationProperties(prefix = "jwt")
@Getter
@Setter
public class JwtProperties {
private String secret;
private long accessTokenValidity;
private long refreshTokenValidity;
}
Java
복사
jwt:
secret: ${JWT_SECRET}
access-token-validity: 3600
refresh-token-validity: 604800
YAML
복사
규칙 42 - Request/Response DTO 코딩 스타일 일관 유지
설명: DTO 변환 메서드명을 fromEntity, toEntity 처럼 통일하고, 요청/응답 DTO는 명확히 구분한다.
@Getter
@NoArgsConstructor
public class BookRequest {
private String title;
private String author;
public Book toEntity() {
return Book.builder()
.title(title)
.author(author)
.build();
}
}
@Getter
@Builder
public class BookResponse {
private String title;
private String author;
public static BookResponse fromEntity(Book book) {
return BookResponse.builder()
.title(book.getTitle())
.author(book.getAuthor())
.build();
}
}
Java
복사
규칙 43 - DTO에도 @Builder 적용, entity → dto 변환 적극 활용
설명: DTO 변환 시 @Builder를 활용해 가독성과 확장성을 높인다.
@Getter
@Builder
public class MemberResponse {
private String username;
private String nickName;
public static MemberResponse fromEntity(Member member) {
return MemberResponse.builder()
.username(member.getUsername())
.nickName(member.getNickName())
.build();
}
}
Java
복사
규칙 44 - Service 계층은 인터페이스로 만들지 않음
설명: 불필요한 인터페이스 계층을 만들지 않고, 바로 구현 클래스를 사용한다.
// ❌ 불필요한 인터페이스
public interface BookService {
BookResponse getBook(Long id);
}
@Service
public class BookServiceImpl implements BookService { ... }
// ✅ 단일 클래스
@Service
@RequiredArgsConstructor
public class BookService {
public BookResponse getBook(Long id) { ... }
}
Java
복사
규칙 47 - entity 작성 시 AccessLevel.PRIVATE/PROTECTED 활용
설명: 엔티티 기본 생성자는 protected 나 private 으로 막고, 빌더나 팩토리 메서드로만 생성한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue
private Long id;
private String username;
private String password;
@Builder
private Member(String username, String password) {
this.username = username;
this.password = password;
}
}
Java
복사
규칙 48 - JPA dirty-checking 활용
설명: setter 대신 엔티티 값 변경 시 JPA 더티체킹으로 update 처리
@Transactional
public void updateNickName(Long memberId, String newNickName) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException("존재하지 않는 회원"));
member.changeNickName(newNickName); // 엔티티 내부 메서드
}
Java
복사
규칙 50 - BaseTime 적용
설명: 엔티티 공통 시간 필드를 @MappedSuperclass 로 관리.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime {
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
}
Java
복사
규칙 51 - BaseTime이나 엔티티에 protected 지양
설명: 생성자 접근 제어 시 불필요하게 protected를 남발하지 않는다.
@NoArgsConstructor(access = AccessLevel.PROTECTED) // ✅ 필요한 경우만
public class Book { ... }
Java
복사
규칙 56 - @AuthenticationPrincipal 활용
설명: 로그인 사용자 정보를 컨트롤러에서 편리하게 주입받는다.
@GetMapping("/me")
public ApiResponse<MemberResponse> getCurrentUser(@AuthenticationPrincipal CustomUserDetail userDetail) {
return ApiResponse.ok(MemberResponse.fromEntity(userDetail.getMember()));
}
Java
복사
규칙 57 - CustomUserDetail 적용
설명: UserDetails를 커스터마이징하여 엔티티와 연동.
@Getter
public class CustomUserDetail implements UserDetails {
private final Member member;
public CustomUserDetail(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() { return member.getPassword(); }
@Override
public String getUsername() { return member.getUsername(); }
}
Java
복사