[10분 테코톡] 캉골의 좋은 코드를 작성하기 위한 기준
좋은 코드를 위해서는 높은 응집도와 낮은 결합도 방식을 추구해야 한다.
응집도(Cohesion)란?
하나의 모듈(클래스, 패키지, 메서드) 내부의 구성 요소들이 얼마나 밀접하게 관련되어 있는지를 나타내는 척도로, “함께 변경되어야 하는 것들이 실제로 함께 있는지?”를 측정하는 지표라고 할 수 있다.
높은 응집도의 예시: 피스톤, 실린더, 크랭크샤프트 등은 모두 자동차 엔진이라는 하나의 목적을 위한 부품
낮은 응집도의 예시: 다용도실은 청소용품, 운동기구, 계절용품이 뒤섞여 있어 특정 목적으로 사용하기 어려움
com.example.ecommerce
├── controller
│ ├── UserController
│ ├── OrderController
│ └── ProductController
├── service
│ ├── UserService
│ ├── OrderService
│ └── ProductService
├── repository
│ ├── UserRepository
│ ├── OrderRepository
│ └── ProductRepository
└── entity
├── User
├── Order
└── Product 문제점:com.example.ecommerce
├── domain
│ ├── user
│ │ ├── controller
│ │ ├── service
│ │ ├── repository
│ │ ├── dto
│ │ └── entity
│ ├── order
│ │ ├── controller
│ │ ├── service
│ │ ├── repository
│ │ ├── dto
│ │ └── entity
│ └── product
│ ├── controller
│ ├── service
│ ├── repository
│ ├── dto
│ └── entity
└── shared
├── config
├── exception
└── utils 장점:클래스는 오직 하나의 책임만 가져야 한다.
클래스가 변경되어야 하는 이유는 오직 하나여야 한다.
한 클래스는 한 가지 일만 잘해야 한다.
// 나쁜 예: 여러 책임이 혼재 (우연적 응집도)
@Service
public class UserService {
public void createUser(UserDto userDto) { ... }
public void sendEmail(String email) { ... } // 이메일 발송 책임
public void generateReport() { ... } // 리포트 생성 책임
public void validateUserData(UserDto userDto) { ... } // 검증 책임
public void calculateTax() { ... } // 세금 계산 책임
}
// 좋은 예: 단일 책임으로 분리 (기능적 응집도)
@Service
public class UserService {
private final EmailService emailService;
private final ReportService reportService;
private final UserValidator userValidator;
public UserService(EmailService emailService,
ReportService reportService,
UserValidator userValidator) {
this.emailService = emailService;
this.reportService = reportService;
this.userValidator = userValidator;
}
public void createUser(UserDto userDto) {
userValidator.validate(userDto);
// 사용자 생성 로직만 담당
User user = User.from(userDto);
userRepository.save(user);
emailService.sendWelcomeEmail(userDto.getEmail());
}
}
// 나쁜 예: 하나의 메서드가 여러 일을 수행
public void processUser(UserDto userDto) {
// 검증
if (userDto.getEmail() == null) throw new IllegalArgumentException();
if (userDto.getName().length() < 2) throw new IllegalArgumentException();
// 사용자 생성
User user = new User();
user.setName(userDto.getName());
user.setEmail(userDto.getEmail());
userRepository.save(user);
// 이메일 발송
String emailBody = "Welcome " + user.getName();
emailService.send(user.getEmail(), "Welcome", emailBody);
// 로깅
logger.info("User created: " + user.getId());
// 통계 업데이트
statisticsService.incrementUserCount();
}
// 좋은 예: 각 메서드가 하나의 명확한 기능만 수행
public void createUser(UserDto userDto) {
validateUser(userDto);
User user = buildUser(userDto);
User savedUser = saveUser(user);
sendWelcomeEmail(savedUser);
logUserCreation(savedUser);
updateStatistics();
}
private void validateUser(UserDto userDto) {
if (userDto.getEmail() == null) throw new IllegalArgumentException("이메일은 필수입니다");
if (userDto.getName().length() < 2) throw new IllegalArgumentException("이름은 2자 이상이어야 합니다");
}
private User buildUser(UserDto userDto) {
return User.builder()
.name(userDto.getName())
.email(userDto.getEmail())
.build();
}
private User saveUser(User user) {
return userRepository.save(user);
}
private void sendWelcomeEmail(User user) {
emailService.sendWelcomeEmail(user.getEmail());
}
private void logUserCreation(User user) {
logger.info("User created: {}", user.getId());
}
private void updateStatistics() {
statisticsService.incrementUserCount();
}검증의 계층별 분리
: 백엔드에서 검증은 목적에 따라 여러 계층으로 나누어 관리해야 한다. 입력 검증은 Presentation Layer에서 형식적 유효성을 검사하고, 비즈니스 검증은 Business Layer에서 도메인 규칙을 확인하며, 데이터 검증은 Persistence Layer에서 데이터 무결성을 보장한다. 각 계층이 자신의 책임에 맞는 검증만 담당하여 응집도를 높일 수 있다.
필드 단위 vs 객체 단위 검증
: 필드 단위 검증은 이메일 형식, 전화번호 패턴과 같은 개별 필드의 유효성을 검사하며 재사용성이 높다. 반면 객체 단위 검증은 “미성년자는 마케팅 동의 불가”, “VIP 고객은 특별 혜택 필수 선택” 같은 복합적인 비즈니스 규칙을 처리한다. 단순한 형식 검증과 복잡한 비즈니스 규칙을 분리하여 각각의 응집도를 높이는 것이 핵심이다.
Bean Validation의 선언적 접근
: Spring의 Bean Validation을 활용하면 검증 로직을 선언적으로 표현할 수 있어 코드의 가독성과 유지보수성이 향상된다. DTO 클래스에 @NotBlank, @Email, @Size 같은 어노테이션을 사용하여 검증 규칙을 데이터와 함께 응집시키면, 관련 정보가 한 곳에 모여 이해하기 쉬워진다.
도메인 모델 내부 검증
: 가장 중요한 것은 핵심 비즈니스 규칙을 도메인 모델 내부에 위치시키는 것이다. 예를 들어 주문 엔티티 내부에 “배송 완료된 주문은 취소 불가”, “주문 후 1시간 경과 시 취소 불가” 같은 규칙을 배치하여 데이터와 검증 로직의 응집도를 최대화한다. 이렇게 하면 비즈니스 규칙이 여러 곳에 흩어지지 않고 해당 도메인 객체와 함께 관리된다.
결합도(Coupling)란?
결합도는 서로 다른 모듈들 간에 얼마나 강하게 연결되어 있는지를 나타내는 척도로, “한 모듈의 변경이 다른 모듈에 얼마나 큰 영향을 미치는지”를 측정하는 지표로, 낮은 결합도를 유지하는 것이 좋은 소프트웨어 설계의 핵심이다.
높은 결합도의 예시: 일체형 데스크톱 컴퓨터의 경우 모니터가 고장나면 본체까지 교체해야 함
낮은 결합도의 예시: 조립식 컴퓨터의 경우 모니터, 키보드, 마우스를 독립적으로 교체 가능함
고수준 모듈은 저수준 모듈에 의존해서는 안 된다.
둘 다 추상화에 의존해야 한다.
추상화는 구체적인 것에 의존해서는 안 된다.
// 나쁜 예: 구체 클래스에 직접 의존
@Service
public class OrderService {
private MySqlOrderRepository repository; // 구체 클래스에 의존
private SmtpEmailService emailService; // 구체 클래스에 의존
public OrderService() {
this.repository = new MySqlOrderRepository(); // 직접 생성
this.emailService = new SmtpEmailService(); // 직접 생성
}
}
// 좋은 예: 인터페이스에 의존
@Service
public class OrderService {
private final OrderRepository repository; // 인터페이스에 의존
private final EmailService emailService; // 인터페이스에 의존
public OrderService(OrderRepository repository, EmailService emailService) {
this.repository = repository;
this.emailService = emailService;
}
}
@Service
public class OrderService {
private final PaymentService paymentService;
private final EmailService emailService;
// 불변성 보장, 필수 의존성 명시
public OrderService(PaymentService paymentService, EmailService emailService) {
this.paymentService = paymentService;
this.emailService = emailService;
}
}@Service
public class UserService {
private EmailService emailService;
private AuditService auditService; // 선택적 의존성
@Autowired
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
@Autowired(required = false) // 선택적 의존성
public void setAuditService(AuditService auditService) {
this.auditService = auditService;
}
}@Service
public class ProductService {
@Autowired
private ProductRepository repository; // 테스트하기 어려움
@Autowired
private CategoryService categoryService; // 불변성 보장 안됨
}// 나쁜 예: 큰 인터페이스 (인터페이스 오염)
public interface PaymentService {
PaymentResult processPayment(PaymentRequest request);
void refundPayment(String paymentId);
PaymentHistory getPaymentHistory(Long userId);
void generatePaymentReport(LocalDate from, LocalDate to);
void sendPaymentNotification(String email);
void updatePaymentSettings(PaymentSettings settings);
}
// 좋은 예: 인터페이스 분리
public interface PaymentProcessor {
PaymentResult processPayment(PaymentRequest request);
void refundPayment(String paymentId);
}
public interface PaymentHistoryService {
PaymentHistory getPaymentHistory(Long userId);
}
public interface PaymentReportService {
void generatePaymentReport(LocalDate from, LocalDate to);
}// 결제 전략 인터페이스
public interface PaymentStrategy {
PaymentResult processPayment(PaymentRequest request);
boolean supports(PaymentMethod method);
}
// 구체적인 전략들
@Component
public class CreditCardPaymentStrategy implements PaymentStrategy {
@Override
public PaymentResult processPayment(PaymentRequest request) {
// 신용카드 결제 로직
return PaymentResult.success("신용카드 결제 완료");
}
@Override
public boolean supports(PaymentMethod method) {
return PaymentMethod.CREDIT_CARD.equals(method);
}
}
// 컨텍스트 클래스
@Service
public class PaymentService {
private final List<PaymentStrategy> paymentStrategies;
public PaymentService(List<PaymentStrategy> paymentStrategies) {
this.paymentStrategies = paymentStrategies;
}
public PaymentResult processPayment(PaymentRequest request) {
PaymentStrategy strategy = paymentStrategies.stream()
.filter(s -> s.supports(request.getPaymentMethod()))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentException("지원하지 않는 결제 방식"));
return strategy.processPayment(request);
}
}// 나쁜 예: Entity 직접 반환으로 높은 결합도
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id); // Entity 직접 반환
// 문제: DB 스키마 변경이 API 응답에 직접 영향
}
}
// 좋은 예: DTO를 통한 계층 분리
@RestController
public class UserController {
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.findById(id);
return UserResponse.from(user); // DTO 변환
// 장점: DB 구조 변경이 API에 미치는 영향 최소화
}
}
public class UserResponse {
private Long id;
private String name;
private String email;
private LocalDateTime createdAt;
// password, 내부 시스템 필드 등은 제외
public static UserResponse from(User user) {
return UserResponse.builder()
.id(user.getId())
.name(user.getName())
.email(user.getEmail())
.createdAt(user.getCreatedAt())
.build();
}
}구조적 개선은 모듈 간의 관계와 시스템 설계에 초점을 맞춘다면, 습관적 개선은 개별 코드 라인과 표현 방식에 집중한다. 둘 다 중요하지만, 습관적 개선은 매일의 코딩 과정에서 자연스럽게 적용할 수 있어 더욱 실용적이다.
다음 아티클은 좋은 코드를 위한 실용적인 습관에 대한 내용이다:
핵심: 의미를 명시적으로 표현하라
DiscountTier.GOLD처럼 의미가 명확한 열거형 사용핵심: 함수 호출 결과를 바로 사용하지 말고 의미 있는 변수에 할당
logPurchase(getUserId(), getItemId()) → userId = getUserId(); logPurchase(userId, itemId)핵심: 스스로를 설명하는 에러는 절반은 해결된 것
핵심: 로그는 값싸지만 인사이트는 소중하다
핵심: 리소스 생성 즉시 해제 코드도 함께 작성
핵심: 가공된 데이터는 시간이 지날수록 신뢰성을 잃는다
핵심: 불변 객체를 적극 활용
응집도와 결합도 같은 구조적 개선도 중요하지만, 매일의 코드 작성 과정에서 이런 좋은 습관을 체계적으로 만들어가는 것이 더욱 현실적이고 효과적인 접근법이다. 작은 습관들이 쌓여 결국 탁월한 코드를 만들어낸다.