-
계층형 아키텍처
- Controller → Facade → Service → Repository 패턴 적용
- 도메인별 패키지 분리 (domain/user, domain/health 등)
- Core 패키지에 공통 기능 집중
-
공통 기능 모듈화
- AOP를 활용한 로깅, 캐싱, 인증, 검증
- MapStruct를 통한 DTO 매핑
- 예외 처리 전역 핸들러
-
기술 스택
- Spring Boot 3.3.3, Java 17
- JPA/Hibernate, MariaDB
- Redis 캐싱
- JWT 인증
- Swagger/OpenAPI 문서화
현재 상태: CareCodeApplicationTests.java만 존재, 단위/통합 테스트 거의 없음
개선 제안:
// 예시: Service 계층 단위 테스트
@ExtendWith(MockitoExtension.class)
class HealthServiceTest {
@Mock
private HealthRecordRepository healthRecordRepository;
@InjectMocks
private HealthService healthService;
@Test
void createHealthRecord_ShouldReturnResponse_WhenValidRequest() {
// Given
HealthCreateHealthRecordRequest request = ...
HealthRecord savedRecord = ...
// When
HealthRecordResponse response = healthService.createHealthRecord(request);
// Then
assertThat(response).isNotNull();
verify(healthRecordRepository).save(any());
}
}구현 계획:
- Service 계층 단위 테스트 (Mockito)
- Repository 계층 통합 테스트 (@DataJpaTest)
- Controller 계층 통합 테스트 (@WebMvcTest)
- API 통합 테스트 (@SpringBootTest + TestRestTemplate)
- 테스트 커버리지 목표: 70% 이상
현재 문제:
IllegalArgumentException과CareServiceException혼용- Facade에서
IllegalArgumentException직접 사용 (예: HealthFacade.java:162) - 예외 메시지가 일관되지 않음
개선 제안:
// 1. 예외 계층 구조 개선
public abstract class CareCodeException extends RuntimeException {
private final ErrorCode errorCode;
private final HttpStatus httpStatus;
}
public class HealthRecordNotFoundException extends CareCodeException {
public HealthRecordNotFoundException(Long id) {
super(ErrorCode.HEALTH_RECORD_NOT_FOUND,
HttpStatus.NOT_FOUND,
"건강 기록을 찾을 수 없습니다: " + id);
}
}
// 2. ErrorCode enum 정의
public enum ErrorCode {
HEALTH_RECORD_NOT_FOUND("H001", "건강 기록을 찾을 수 없습니다"),
CHILD_NOT_FOUND("H002", "아동을 찾을 수 없습니다"),
// ...
}
// 3. 전역 예외 핸들러 개선
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CareCodeException.class)
public ResponseEntity<ErrorResponse> handleCareCodeException(CareCodeException ex) {
return ResponseEntity
.status(ex.getHttpStatus())
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage()));
}
}구현 계획:
- 예외 계층 구조 정의
- ErrorCode enum 생성
- 모든 Facade/Service에서 일관된 예외 사용
- 예외 응답 형식 표준화
현재 상태: /api/v1 하드코딩, 버전 관리 전략 없음
개선 제안:
// 1. 버전별 Controller 분리
@RestController
@RequestMapping("/api/v1/health")
public class HealthControllerV1 { ... }
@RestController
@RequestMapping("/api/v2/health")
public class HealthControllerV2 { ... }
// 2. 또는 URL 경로 버전 관리
@RestController
@RequestMapping("/api/health")
@ApiVersion("v1")
public class HealthController { ... }
// 3. 헤더 기반 버전 관리
@RestController
@RequestMapping("/api/health")
public class HealthController {
@GetMapping(headers = "API-Version=v1")
public ResponseEntity<?> getV1() { ... }
}구현 계획:
- API 버전 관리 전략 수립
- 버전별 문서화
- Deprecation 정책 정의
현재 문제:
application.properties에 모든 설정 집중- 환경별 설정 분리 부족
- 민감 정보 관리 개선 필요
개선 제안:
src/main/resources/
├── application.yml (기본)
├── application-dev.yml
├── application-staging.yml
├── application-prod.yml
└── application-docker.yml
# application.yml
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
# application-prod.yml
logging:
level:
root: WARN
com.carecode: INFO
file:
name: /var/log/carecode/application.log구현 계획:
- YAML 형식으로 전환 (가독성 향상)
- 환경별 프로파일 분리
- 외부 설정 파일 지원 (ConfigMap/Secret)
- 설정 검증 로직 추가
현재 상태: 기본 로깅만 사용, 구조화된 로깅 부족
개선 제안:
// 1. 구조화된 로깅 (JSON 형식)
@Slf4j
public class HealthService {
public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest request) {
MDC.put("userId", request.getUserId());
MDC.put("childId", request.getChildId());
log.info("건강 기록 생성 시작",
kv("childId", request.getChildId()),
kv("recordType", request.getRecordType()));
try {
// ...
log.info("건강 기록 생성 완료", kv("recordId", savedRecord.getId()));
return response;
} catch (Exception e) {
log.error("건강 기록 생성 실패",
kv("childId", request.getChildId()),
kv("error", e.getMessage()),
e);
throw e;
} finally {
MDC.clear();
}
}
}
// 2. 로깅 설정 (logback-spring.xml)
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<version/>
<logLevel/>
<message/>
<mdc/>
<stackTrace/>
</providers>
</encoder>
</appender>
</configuration>구현 계획:
- Logback 설정 개선 (JSON 형식)
- MDC를 활용한 트레이싱
- 로그 레벨 환경별 설정
- 로그 집계 시스템 연동 (ELK Stack 등)
현재 상태: @CacheableResult 커스텀 어노테이션 사용, 제한적
개선 제안:
// 1. 캐싱 전략 명시
@Cacheable(
value = "healthRecords",
key = "#childId + '_' + #page + '_' + #size",
unless = "#result == null || #result.isEmpty()"
)
public List<HealthRecordResponse> getHealthRecords(Long childId, int page, int size) {
// ...
}
// 2. 캐시 무효화 전략
@CacheEvict(value = "healthRecords", allEntries = true)
public HealthRecordResponse createHealthRecord(...) {
// ...
}
// 3. 캐시 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}구현 계획:
- 캐싱 전략 문서화
- TTL 설정 최적화
- 캐시 히트율 모니터링
- 분산 캐시 고려 (Redis Cluster)
현재 문제:
- Facade와 Service 모두에
@Transactional중복 - 읽기 전용 트랜잭션 명시 부족
- 트랜잭션 전파 전략 불명확
개선 제안:
// 1. Service 계층에만 트랜잭션 적용
@Service
@Transactional(readOnly = true) // 기본값
public class HealthService {
@Transactional // 쓰기 작업만 명시
public HealthRecordResponse createHealthRecord(...) {
// ...
}
// 읽기 작업은 기본값 사용
public HealthRecordResponse getHealthRecordById(Long id) {
// ...
}
}
// 2. Facade는 트랜잭션 없이 위임만
@Service
public class HealthFacade {
private final HealthService healthService;
// 트랜잭션 어노테이션 제거 (Service에 위임)
public HealthRecordResponse createHealthRecord(...) {
return healthService.createHealthRecord(...);
}
}구현 계획:
- 트랜잭션 경계 명확화
- 읽기 전용 트랜잭션 최적화
- 트랜잭션 전파 전략 문서화
개선 제안:
// 공통 응답 래퍼
public class ApiResponse<T> {
private String code;
private String message;
private T data;
private LocalDateTime timestamp;
}
// Controller에서 일관된 응답
@RestController
public class HealthController {
@GetMapping("/records/{id}")
public ResponseEntity<ApiResponse<HealthRecordResponse>> getRecord(@PathVariable Long id) {
HealthRecordResponse data = facade.getHealthRecordById(id);
return ResponseEntity.ok(ApiResponse.success(data));
}
}개선 제안:
// 1. Bean Validation 활용
public class HealthCreateHealthRecordRequest {
@NotBlank(message = "제목은 필수입니다")
private String title;
@NotNull(message = "아동 ID는 필수입니다")
@Positive(message = "아동 ID는 양수여야 합니다")
private Long childId;
@PastOrPresent(message = "기록일은 과거 또는 현재여야 합니다")
private LocalDate recordDate;
}
// 2. 커스텀 검증
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ChildAgeValidator.class)
public @interface ValidChildAge {
String message() default "아동 연령이 유효하지 않습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}개선 제안:
- API 문서 자동화 강화
- 아키텍처 다이어그램 추가 (C4 Model)
- 개발자 온보딩 가이드
- 배포 가이드 상세화
Controller → Facade → Service → Repository
Controller → Application Service (Facade) → Domain Service → Repository
↓
Domain Model (Entity)
장점:
- 도메인 로직이 Entity에 집중
- Service는 오케스트레이션만 담당
- 테스트 용이성 향상
// 현재 (문제)
public List<HealthRecordResponse> getHealthRecordsByUserId(String userId) {
List<HealthRecord> records = repository.findByUserId(userId);
return records.stream()
.map(mapper::toResponse) // 각각 Child 조회 발생
.collect(Collectors.toList());
}
// 개선
@Query("SELECT r FROM HealthRecord r JOIN FETCH r.child WHERE r.user.id = :userId")
List<HealthRecord> findByUserIdWithChild(@Param("userId") Long userId);// 현재
public List<HealthRecordResponse> getHealthRecords(Long childId, int page, int size) {
Page<HealthRecord> records = repository.findByChildId(childId, pageable);
return records.getContent().stream() // 전체 조회 후 변환
.map(mapper::toResponse)
.collect(Collectors.toList());
}
// 개선: DTO 직접 조회
@Query("SELECT new com.carecode.domain.health.dto.response.HealthRecordResponse(...) " +
"FROM HealthRecord r WHERE r.child.id = :childId")
Page<HealthRecordResponse> findDtoByChildId(@Param("childId") Long childId, Pageable pageable);@RestController
@Validated
public class HealthController {
@PostMapping("/records")
public ResponseEntity<?> createRecord(
@Valid @RequestBody HealthCreateHealthRecordRequest request) {
// ...
}
}- JPA 사용으로 대부분 방지됨
- Native Query 사용 시 파라미터 바인딩 확인 필요
@RateLimit(maxRequests = 100, windowSeconds = 60)
@PostMapping("/records")
public ResponseEntity<?> createRecord(...) {
// ...
}- ✅ 예외 처리 일관성 개선
- ✅ 테스트 코드 작성 시작 (핵심 기능)
- ✅ 설정 파일 환경별 분리
- 로깅 전략 개선
- 캐싱 전략 확장
- 트랜잭션 관리 개선
- API 응답 형식 표준화
- API 버전 관리 도입
- 성능 최적화 (N+1 쿼리 등)
- 문서화 개선
- 모니터링 및 알림 시스템 구축
현재 프로젝트는 견고한 기반 구조를 가지고 있으나, 테스트 코드 부족과 예외 처리 일관성 문제가 가장 시급합니다.
위 개선 사항들을 단계적으로 적용하면:
- ✅ 코드 품질 향상
- ✅ 유지보수성 개선
- ✅ 안정성 강화
- ✅ 개발 생산성 향상
을 기대할 수 있습니다.