Level 1 전체 회고

2026. 4. 2. 13:36·우아한테크벨로/Level 1
728x90

객체지향적 사고하기

level1을 진행하면서 객체지향의 사실과 오해를 읽었다. 우테코를 시작하기 전에는 단순히 이것저것 주워들은 내용들을 가지고 주먹구구식으로 코드를 짜왔었다. 사실상 스프링에 의존해서 코드를 작성해온 것이라고 봐도 될 것 같다.

하지만,

이번에는 다르다. 처음으로 제대로 된 고민을 하기 시작하고, 미션을 진행하면서 부족한 개념들에 대해서도 눈에 들어왔다. 그래서 처음으로 객체지향의 개념과 관련된 책을 읽기로 결정한 것이다. 이를 통해서 객체 지향적 설계란 무엇인지? 책임의 분리는 어떻게 하는 것인지, 각각의 도메인 구성요소에게 책임을 부여하고, 객체 스스로가 일을 하게끔 시키는 방법은 어떻게 해야하는 것인지 등을 알 수 있었고, 객체지향적 설계에 대해서 조금은 눈을 뜬 것 같다.

매번 무작정 구현부터 시작해왔던 과거와 달리 이제는 구현을 하기 전 설계의 중요성에 대해서 신경쓰게 되었다.

책의 내용 중에는 이런 내용이 있었다.

 

객체에 집중하지 말고, 메시지에 집중해라

 

이 말을 듣기전, 설계를 하는 과정에서 무의식적으로 "이 객체는 무엇을 하는거지?"라는 생각으로 하나의 객체에 고립된 생각을 하다보면 고립된 섬과 같은 객체를 만들게 되었다. 매번 그렇게 작성해오면서 항상 객체 간의 연결을 하는 과정에서 어려움을 겪고, 설계가 망가지는 경우가 태반이었다.

 

하지만 이제는,

객체간의 협력을 위해 전달해야하는 메시지에 집중하게 되었다. 도메인을 구현하는 과정에서 메시지가 무엇이 필요한지 먼저 생각했다. 그리고 메시지가 무엇이고, 이 메시지를 담당하는 객체는 어떤것들인지는 그 다음에 보는 방식으로 객체가 아닌 메시지에 집중하는 방식을 통해서 설계를 진행하면서 고립된 객체가 아닌 객체 간의 소통을 만들어낼 수 있게 되었다.

 

도메인이란 무엇인가?

블랙잭 미션을 진행하면서 리뷰어에게 리뷰를 받으면서 의문과 궁금증 그리고 여지껏 밀어두었던 개념에 대해서 해소하고자 결심하게 되었다.

첫 번째 PR을 보냈을 때,

처음 받았던 내용은 Service와 Controller의 필요유무와 역할에 대한 내용을 코멘트로 받았다. 여지껏 남발해오던 MVC 패턴에 대해서 제대로 머리를 맞는 피드백이었다. 이 과정에서 "도메인이란 무엇인가?", "내가 생각해오던 도메인은 무엇인가?" 혼란과 함께 정리의 필요성을 느끼게 되었다. 그래서 객체지향의 사실과 오해를 읽게 된 것이다. 이를 통해서 도메인이 너무 많은 의미를 가지고 있다는걸 깨달았다. 네트워크에서도 도메인이 있고, 객체지향에서도 도메인이 있고, DDD(Domain Driven Design)에서의 도메인이 있고, 각각 다른 의미와 Scope를 가지고 있다는걸 깨달았다. 그래서 객체지향에서의 도메인은 사용자가 프로그램을 사용하는 대상 영역에 관한 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태라고 한다. 나는 여지껏 DDD에서의 도메인과 객체지향에서의 도메인을 혼동하고 있었다. 같은 도메인이라고 생각해왔지만 둘의 Scope는 달랐다.

구분 객체지향 DDD
바라보는 시선 미시적/전술적 거시적/전략적 + 전술적
주요 관심사 클래스, 인터페이스, 캡슐화, 다형성 비즈니스 목적, 서브 도메인 분리, 보편적 언어
설계의 중심 개발자 중심
(어떻게 깔끔하게 코드를 짤까?)
비즈니스 중심
(어떻게 기획자의 의도를 정확히 구현할까?)
언어의 주체 개발자들의 기술 용어
(ex. List, Manger, Dto)
도메인 전문가와 개발자의 공통 용어
(ex. Order, Payment, Cancel)
객체의 크기 시스템 전체에서 1개의 거대한 엔티티를 공유하려는 경향 각 비즈니스 문맥(Context)마다 객체를 작게 쪼개서 분리

위 표를 통해서 나만의 도메인 결론을 내렸다. DDD의 도메인은 객체지향적 설계를 통해 만든 도메인들의 조합이다. 즉, 객체지향에서 얘기하는 도메인들로 DDD의 도메인을 구현하는 것이다.

비유하자면,

객체지향은 무기이고, DDD는 무기를 사용한 전략이다.
OOP는 재료이고, DDD는 그 재료를 조립하는 설계도이다.
OOP의 원칙으로 잘 빚어낸 도메인 객체들을 목적에 맞게 조립하고 묶어서, DDD에서 말하는 거대한 비즈니스 도메인(영역)을 구현하는 것이다.

 

JVM와 JMH, JIT 컴파일러

장기 미션을 진행하면서 경량 패턴(Flyweight Pattern)을 도입하여 기물의 위치를 가리키는 Point를 구현하였다. 이것에 대해서 리뷰어의 코멘트를 받게되면서 JMH를 알게되었다. 리뷰어는 경량 패턴과 단순한 new를 사용한 객체 생성 중에서 캐시를 적용하는 경량 패턴을 사용하는 것이 성능 측면에서 정말 좋은지? 적용해야하는지?에 대해서 리뷰 코멘트와 실험 코드를 제안하면서 성능을 실제로 측정 해봤는지에 대해서 지적했다.

이때 나는 캐시를 사용하면 당연히 좋겠지라는 생각을 가지고 적용을 했던 것이 "어? 실제로는 다를 수도 있겠구나. 실험이 필요하겠네."라는 생각이 들게 했다. 메모리 사용측면에서는 당연히 좋을 것이라고 생각했고, Garbage Collection의 부하도 덜어줄거라고 생각했다. 그래서 속도측면에서도 좋을 것이라고 생각을 했다.

그런데 리뷰어가 제시해준 코드에서는 다른 결과가 나온 것이다. 그래서 "내가 알고 있던 내용이 틀린 것인가?", "그렇다면 캐시는 왜 사용하는거지?" 의문이 들었다. 동시에 "리뷰어의 테스트 코드가 정말 내가 사용하려고 한 의도에 대한 실험 결과를 구현한 코드가 맞는가?" 또한 의심이 들었다.

그 결과,

JVM이 어떻게 처리 했는지 생각해보게 되었다. 리뷰어가 처음 제공해준 테스트 코드는 아래와 같았다.

    public static void main(String[] args) {
        testCache();
        testWithoutCache();
    }

    private static void testWithoutCache() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100_000_000; i++) {
            new Point(i % 9, i % 10);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time taken without cache: " + (endTime - startTime));
    }

    private static void testCache() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100_000_000; i++) {
            Point.of(i % 9, i % 10);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time taken with cache: " + (endTime - startTime));
    }

단순하게 시간을 계산하는 로직이고, 객체 생성만 하는 동작의 코드이다.

이 코드를 돌려보았을 때는 단순히 캐시를 안쓰는 것이 더 성능이 좋구나로 판단할 수 있다. 하지만 실제 JVM의 JIT 컴파일러를 알면 내용은 달라졌다.

Dead Code Elimination

문제: 루프 안에서 Point를 열심히 만들고 있지만, 정작 그 만들어진 객체를 변수에 담거나 출력하는 등 '아무 곳에도 사용하지 않고' 있다.

JVM의 행동: JIT 컴파일러는 코드를 스캔하다가 "어라? 기껏 객체를 만들어놓고 안 쓰네? 그럼 굳이 메모리 낭비하면서 만들 필요 없지!" 하고 객체 생성 코드 자체를 통째로 삭제(무시)해 버린다. (Escape Analysis 최적화)

결과: 결국 컴퓨터는 1억 번의 '빈 루프(Empty Loop)'만 헛돌고 끝날 수 있으며, 객체 생성 시간이 아니라 빈 루프 도는 시간을 재고 있게 된다.

JVM 예열 문제, Warm-up

문제: 지금 testCache()가 먼저 실행되고, 그다음 testWithoutCache()가 실행된다.

JVM의 행동: 자바 코드는 처음 실행될 때는 느리다(인터프리터 모드). 그러다 코드가 반복 실행되면 JIT 컴파일러가 "이거 자주 쓰네!" 하고 기계어로 번역해서 엄청나게 빠르게 만들어준다(컴파일 모드).

결과: 첫 번째로 실행되는 testCache()는 예열하느라 느리게 측정되고, 뒤에 달리는 testWithoutCache()는 이미 예열된 쾌적한 환경에서 달리기 때문에 성능 버프를 받을 수 있다. 두 메서드의 호출 순서를 바꾸면 결과가 또 확 달라질 수 있다.

측정 시간에 섞여 들어간 '청소 시간' (Garbage Collection 폭탄)

문제: testWithoutCache()는 순식간에 1억 개의 Point 쓰레기를 메모리에 투척한다.

JVM의 행동: 메모리가 꽉 차면 가비지 컬렉터가 "잠깐 다 멈춰!" (Stop-The-World)를 외치고 쓰레기 청소를 시작한다.

결과: 실제로 잰 시간은 순수한 '객체 생성 시간'이 아니라, '객체 생성 시간 + 중간중간 멈춰서 쓰레기 치운 시간'이 합쳐진 결과다.

(물론 GC를 줄이는 게 플라이웨이트 패턴의 궁극적인 목적이긴 하지만, 순수 '생성 속도'를 비교하는 통제된 실험 관점에서는 오차가 너무 크다.)

 

이러한 문제들을 해결하고, 실험을 진행하기 위해서,

 

JMH(Java Microbenchmark Harness)를 통한 실험

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class PointBenchmark {

    private int col = 8;
    private int row = 9;

    @Benchmark
    public void withCache(Blackhole blackhole) {
        blackhole.consume(Point.of(col, row));
    }

    @Benchmark
    public void withoutCache(Blackhole blackhole) {
        blackhole.consume(new Point(col, row));
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(PointBenchmark.class.getSimpleName()) 
                .warmupIterations(3)     
                .measurementIterations(5)
                .forks(1)                
                .build();

        new Runner(opt).run(); 
    }
}

 

 

벤치마크를 사용하여, 위와 같은 테스트 코드를 통해 실험을 다시 진행해보았다.(상수 폴딩(Constant Folding) 문제로 코드를 수정하였다. 기존에는 필드로 따로 두지 않고, 상수를 Point에 넣어주어서 상수 폴딩 문제로 다른 결과가 나왔었다.)

우선 JMH가 무엇인가 하면, 자바를 만든 OpenJDK 팀에서 직접 개발한 '초정밀 성능 측정(마이크로 벤치마크) 공식 프레임워크'다.

Blackhole

만들어진 Point 객체를 blackhole.consume(point)라는 메서드에 던져주면, JVM은 "아! 이 객체 블랙홀이라는 곳에서 중요하게 쓰고 있구나!" 하고 코드를 지우지 않고 끝까지 실행한다.

즉, 기존에 JIT 컴파일러가 escape analysis 기반으로 dead code elimination을 수행하던 작업을 하지 않게 만들어준다.

Warm-up

이전에 처음 실행한 코드와 나중에 실행한 코드 간에 JVM 예열 문제로 인터프리터 모드와 컴파일 모드 간의 속도 차이가 발생하는 실험 환경 통제 문제를 해결한다. 즉 코드를 돌리기 전에 미리 예열 과정을 거쳐서 컴파일 모드로 만든 후 성능을 측정하는 것이다.

Fork

A 메서드를 테스트 하면서 생긴 메모리 찌꺼기나 JVM 상태가 B 메서드를 테스트 할 때 영향을 줄 수 있다.

새로운 JVM을 띄워서 테스트를 진행하게 해준다. 즉, 물리적으로 완전히 격리한다.

 

실제 실험 결과

실제 실험의 결과는 예상치 못한 결과가 나왔다. 생각했던 것보다 new 연산자의 속도는 엄청 빠르다는 것을 알게되었다.

결국 캐시를 쓰는 것은 트레이드 오프라는 것이다.

속도 측면에서는 단순히 new 연산자를 쓰는 것이 좋은 설계 방향일 것이다. 하지만 JVM의 메모리 할당 최적화와 GC(Garbage Collection)의 지연 현상(Stop-The-World)을 방지하는 것이 전체 시스템 안정성 측면에서는 더 이득일 수 있다.

따라서 현재 프로젝트의 크기나, 특성에 따라서 캐시를 사용 할 것인지 말 것인지를 트레이드 오프 고려해서 설계 해야한다는 결론이 나왔다.

 

Level 1 한줄 후기 : 스스로 성장하는 동기부여 단계

 

728x90

'우아한테크벨로 > Level 1' 카테고리의 다른 글

Level1 1주차 회고  (0) 2026.03.02
'우아한테크벨로/Level 1' 카테고리의 다른 글
  • Level1 1주차 회고
Bello's
Bello's
개발하는 벨로
  • Bello's
    벨로의 개발일지
    Bello's
  • 전체
    오늘
    어제
    • 분류 전체보기 (201)
      • 노예 일지 (7)
        • 스타트업 노예일지 (3)
      • CS 이론 (81)
        • 학과 수업 (4)
        • 알고리즘 (64)
        • 시스템 프로그래밍 (3)
        • 데이터 통신 (1)
        • 운영체제 (2)
        • 데이터베이스 (1)
      • project (3)
      • 나는 감자다. (4)
      • Spring (27)
      • 모각코 (45)
        • 절개와지조(모각코) (7)
        • 어쩌다보니 박준태가 조장이조 (11)
        • 어쩌다보니 박준태가 또 조장이조 (12)
      • LikeLion🦁 (20)
      • 캘리포니아 감자 (4)
      • OpenSource Contribute (1)
      • 우아한테크벨로 (9)
        • 프리코스 회고록 (6)
        • Level 1 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Spring
    모각코
    자바
    8기
    감자
    BFS
    JPA
    그래프 순회
    오블완
    어렵다
    나는 감자
    타임리프
    뛰슈
    백준
    회고록
    프리코스
    절개와지조
    누적합
    우테코
    DFS
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
Bello's
Level 1 전체 회고
상단으로

티스토리툴바