[SOPT 세미나] 2차 - 서버 개발의 구조

시훈·2025년 5월 2일

SOPT 36기

목록 보기
3/6

"잘 동작하는 코드"와 "잘 설계된 코드"는 다르다

이번 세미나는 단순히 기능을 구현하는 걸 넘어서

설계와 구조, 그리고 통신의 본질까지 짚어가는 시간이었다.

객체지향, 예외 처리, SOLID, 동시성, HTTP/REST, Spring Boot

한 주제씩 제대로 이해하고 넘어가는 게 훨씬 더 중요하다는 걸 느꼈다.

기계적인 코딩에서 벗어나 본질을 다시 학습하는 것의 중요성을 느꼈고 결정적으로 재밌었다.


📌 Domain vs Entity - 단순 데이터인가, 책임이 있는가?

Entity와 Domain은 코드 상으로는 거의 동일하게 생겼지만,

역할과 해석이 전혀 다르다.

  • Entity:
    • DB 테이블과 매핑되는 클래스.
    • @Entity 어노테이션을 통해 영속성 컨텍스트에 의해 관리됨.
    • equals(), hashCode()는 ID 기반으로, getter/setter 위주 구조.
@Entity
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    // Getter, Setter, equals, hashCode ...
}
  • Domain:
    • 비즈니스 로직을 수행하는 핵심 모델.
    • 데이터와 책임(행위)을 함께 가짐.
    • Entity를 포함하거나 별도로 존재할 수도 있음.
public class PostTitle {
    private final String value;

    public PostTitle(String value) {
        if (value == null || value.length() > 30) {
            throw new IllegalArgumentException("제목은 30자 이하여야 합니다.");
        }
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

👉 Entity는 데이터 저장에 집중, Domain은 의미와 제약을 갖는다.


✅ 유효성 검사는 Layer마다 책임이 다르다

유효성 검사는 크게 두 가지 관점으로 나뉜다.

  1. 입력 값이 유효한가?Controller
  2. 비즈니스적으로 유효한가?Service

예시

// DTO의 유효성 검사
public record PostCreateRequest(
    @NotBlank(message = "제목은 필수입니다.")
    @Size(max = 30, message = "제목은 30자를 넘을 수 없습니다.")
    String title
) {}

Controller 단에서 @Valid를 통해 요청 자체의 형식을 검증하고,

이후 Service 단에서는 중복 여부 같은 도메인 룰을 검증한다.

public void createPost(String title) {
    if (postRepository.existsByTitle(title)) {
        throw new DuplicateTitleException("이미 존재하는 제목입니다.");
    }
    // ...
}

결국 "책임의 위치"가 중요한 것이다.


🔄 DTO → Entity 변환, 왜 Service에서 해야 할까?

"Controller에서 new Post 하면 안 돼요?"

물론 간단한 경우엔 가능하다. 하지만…

  • 외부 API 호출로 추가 정보가 필요한 경우
  • 여러 도메인 객체를 조합해야 하는 경우
  • 생성 과정 자체가 복잡한 로직일 경우

Service가 Domain을 조립해야 한다.

public Post createPost(PostCreateRequest request) {
    User user = userRepository.findById(request.userId())
        .orElseThrow(() -> new EntityNotFoundException("User not found"));

    PostTitle title = new PostTitle(request.title());
    Post post = new Post(title, user);

    return postRepository.save(post);
}

즉, Entity 생성은 도메인 조립의 일부이며, Controller는 그 책임을 가지면 안 된다.


⚠️ 예외 처리는 '시스템 생존 전략'이다

✔ CheckedException vs UncheckedException

종류대표 클래스특징
CheckedIOException, SQLException반드시 try-catch 또는 throws 필요
UncheckedNullPointerException, IllegalArgumentException컴파일러 강제 없음

CheckedException은 복구 가능한 예외,

UncheckedException은 대부분 개발자 실수나 로직 버그다.


예외 처리 전략

  • try-catch → 외부 자원 작업 (파일, 네트워크)
  • throw → 도메인 룰 위반
  • throws → 책임 전가 (라이브러리 메서드 등)
try {
    BufferedReader br = new BufferedReader(new FileReader("data.txt"));
    String line = br.readLine();
} catch (IOException e) {
    throw new CustomFileReadException("파일을 읽는 중 에러 발생", e);
}

서비스가 죽지 않고 살아남으려면, 모든 예외 흐름을 통제할 수 있어야 한다.


🧠 SOLID - 설계의 기준을 만드는 5가지 원칙

1️⃣ SRP - 단일 책임 원칙

하나의 클래스는 하나의 이유로만 변경되어야 한다

❌ 나쁜 예

public class PostService {
    public void createPost(String title) {
        int id = postId++;
        Post post = new Post(id, title);
        postRepository.save(post);
    }
}

✅ 개선 예

public class IdGenerator {
    private AtomicInteger id = new AtomicInteger(1);
    public int generate() {
        return id.getAndIncrement();
    }
}

2️⃣ OCP - 개방-폐쇄 원칙

확장엔 열려 있고, 수정엔 닫혀 있어야 한다

❌ if문에 의존

if (paymentMethod.equals("card")) { ... }
else if (paymentMethod.equals("kakao")) { ... }

✅ 다형성으로 확장

interface Payment {
    void pay();
}
class CardPayment implements Payment { public void pay() {...} }
class KakaoPay implements Payment { public void pay() {...} }

3️⃣ DIP - 의존 역전 원칙

추상화에 의존해야 한다 (구체 클래스 X)

public class PostService {
    private final PostRepository repository;

    public PostService(PostRepository repository) {
        this.repository = repository;
    }
}

→ 나중에 Spring의 DI 컨테이너가 이 역할을 대신 해준다.


4️⃣ LSP - 리스코프 치환 원칙

부모 객체는 자식 객체로 대체 가능해야 한다

예외를 던지는 자식은 위험하다.

class FilePostRepository implements PostRepository {
    public void save(Post post) {
        throw new UnsupportedOperationException("지원하지 않는 기능");
    }
}

이런 구현체는 대체 불가능하며, LSP를 위반한다.


5️⃣ ISP - 인터페이스 분리 원칙

인터페이스는 클라이언트가 사용하는 메서드만 가져야 한다

interface FullRepository {
    void save(); void migrate(); void syncToS3();
}

→ 일부 구현체는 불필요한 메서드를 억지로 구현해야 함

→ 작고 명확한 인터페이스로 나누는 게 이상적이다


🧵 동시성 - 서버 개발자가 피할 수 없는 첫 관문

java
복사편집
private int postId = 1;

public void createPost(String title) {
    int id = postId++;
    // 동시 요청 시, 동일한 ID가 할당될 수 있음
}

➡ 이건 전형적인 Race Condition이다.

해결책: synchronized, AtomicInteger, DB Auto-Increment 등

private final AtomicInteger postId = new AtomicInteger(1);

public void createPost(String title) {
    int id = postId.getAndIncrement();
}

공유 자원에는 락이 필요하다.


🌐 HTTP와 REST - 서버와의 약속을 지키는 방식

HTTP 메시지 구조

POST /posts HTTP/1.1
Content-Type: application/json

{
  "title": "Spring은 언제 배워도 어렵다"
}
  • Start Line: 무엇을 요청하는지 (GET /posts)
  • Header: 요청/응답의 부가 정보 (Content-Type)
  • Body: 실제 데이터

RESTful이란?

자원을 명확하게 식별하고, 상태를 표현하며, 일관된 인터페이스를 사용하는 아키텍처 스타일

  • URI는 명사형 (/posts/1)
  • 메서드는 행위 표현 (GET, POST, PUT, DELETE)
  • 응답은 상태 코드로 구체적으로 표현 (200, 201, 404, 500)
HTTP/1.1 201 Created
Location: /posts/1

REST는 단순한 스타일이 아니라 웹을 어떻게 설계할 것인가에 대한 철학이다.


🧪 그리고 Spring Boot, 그 시작

  • @RestController로 HTTP 엔드포인트 연결
  • @RequestBody → JSON → DTO
  • @Service, @Repository → 책임 분리
  • @Entity → JPA 기반 ORM 매핑

Spring은 우리가 배운 모든 개념을 실제 웹 애플리케이션으로 연결시켜주는 프레임워크이다.


✅ 마무리하며

이번 세미나는 단순히 코드를 배우는 게 아니라 코드를 바라보는 눈을 키우는 시간이었다.
객체를 만들고, 역할을 나누고, 설계를 고민하고, 통신을 설계하고,에러를 통제하고, 사용자와 대화하는 방식까지.

profile
Backend Developer / Cloud Engineer

0개의 댓글