UML
UML은 무엇인가?
UML(Unified Modeling Language, 통합 모델링 언어)은 소프트웨어의 구조와 동작을 그림으로 표현하는 표기 언어다. 클래스 사이의 관계, 객체가 메시지를 주고받는 순서, 상태가 바뀌는 조건처럼 코드에서 한눈에 보기 어려운 내용을 골라 보여 준다.1
UML은 구조와 동작을 표현하는 공통 표기법이다. 같은 관계를 손으로 그릴 수도 있고, 편집기나 텍스트 기반 도구로 그릴 수도 있다. 프로그래밍 언어와 개발 방법, 그림 도구는 별도로 고른다.
왜 사용하는가?
작은 진단 출력기를 하나 예로 들어 보자. 오류 데이터는 같은데도 사람이 읽는 문장으로 만들 때와 JSON으로 만들 때는 방식이 다르다. 데이터를 정해진 표현 형식으로 바꿔 주는 역할을 포매터(Formatter)라고 부른다.
출력기가 구체적인 변환 방식을 전부 알고 있으면, 형식이 하나 늘 때마다 출력기 코드도 같이 바뀐다. 그래서 출력기는 공통 계약만 알고 실제 변환은 넘겨받은 포매터에 맡기게 나눈다. 여기서 말하는 계약은 “진단을 받아 문자열을 돌려준다”는 호출 규칙이다.
UML로 그려 두면 “누가 누구를 참조하는가”, “새 형식을 추가할 때 어느 타입이 바뀌는가”를 코드 전체를 읽기 전에 확인할 수 있다. 설계 회의나 코드 리뷰에서 같은 구조를 가리키며 말할 때도 편하다. 다만 그림이 맞다고 해서 구현의 오류까지 사라지는 것은 아니다.
질문에 맞는 그림을 고른다
알고 싶은 것 | 주로 쓰는 다이어그램 |
|---|---|
타입의 역할과 관계 | 클래스 다이어그램 |
한 상황에서 누가 누구를 어떤 순서로 호출하는가 | 시퀀스 다이어그램 |
현재 상태에서 어떤 사건이 허용되고 어디로 바뀌는가 | 상태 머신 다이어그램 |
작업의 분기·반복·병렬 흐름 | 활동 다이어그램 |
큰 소프트웨어 구성 단위와 연결 | 컴포넌트 다이어그램 |
프로그램이 어느 장치·실행 환경에 배치되는가 | 배치 다이어그램 |
- 알고 싶은 것
타입의 역할과 관계
- 주로 쓰는 다이어그램
클래스 다이어그램
- 알고 싶은 것
한 상황에서 누가 누구를 어떤 순서로 호출하는가
- 주로 쓰는 다이어그램
시퀀스 다이어그램
- 알고 싶은 것
현재 상태에서 어떤 사건이 허용되고 어디로 바뀌는가
- 주로 쓰는 다이어그램
상태 머신 다이어그램
- 알고 싶은 것
작업의 분기·반복·병렬 흐름
- 주로 쓰는 다이어그램
활동 다이어그램
- 알고 싶은 것
큰 소프트웨어 구성 단위와 연결
- 주로 쓰는 다이어그램
컴포넌트 다이어그램
- 알고 싶은 것
프로그램이 어느 장치·실행 환경에 배치되는가
- 주로 쓰는 다이어그램
배치 다이어그램
패턴 문서에는 클래스·시퀀스·상태 머신 다이어그램이 자주 나온다. 그런 그림을 읽으려면 먼저 상자와 선이 각각 무엇을 뜻하는지 알아야 한다.
클래스 상자는 위에서 아래로 읽는다
DiagnosticReporter는 이 타입의 이름이다.- formatter: IDiagnosticFormatter는 formatter라는 비공개 속성이다. 콜론 뒤에 오는 것이 그 속성의 타입이다.+ report(d: Diagnostic): String은 Diagnostic 타입의 인수 d를 받아 문자열을 돌려주는 공개 연산이다. 구현 코드로 옮기면 보통 메서드가 된다.
멤버 앞에 붙는 +는 public, -는 private, #는 protected, ~는 package 가시성을 뜻한다. 여기서 package는 UML에서 요소를 묶어 두는 이름 공간인데, 이걸 특정 언어의 폴더나 internal과 무조건 같은 뜻으로 읽으면 곤란하다.
이름 위의 «interface»는 인터페이스라는 뜻이다. 추상 타입이나 추상 연산은 기울임꼴로 표시할 수 있고, 정적 멤버는 밑줄로 표시한다. 그림은 설명에 필요한 구획과 멤버를 골라 보여 주므로, 전체 구성은 코드와 함께 확인한다.2
관계선은 선 모양과 끝 모양을 함께 본다
일반화(Generalization)는 구체적인 타입에서 더 일반적인 타입으로 실선을 긋고 빈 삼각형을 붙인다. 클래스 상속이 대표적인 예다.
실체화(Realization)는 구현하는 타입에서 계약 쪽으로 점선과 빈 삼각형을 그린다. 클래스가 인터페이스를 구현하는 관계를 이렇게 표현한다.
의존(Dependency)은 사용하는 쪽에서 필요한 쪽으로 점선과 열린 화살표를 그린다. 메서드의 매개변수 타입을 쓰는 경우가 한 예다.
연관(Association)은 인스턴스 사이에 이어지는 관계를 실선으로 나타낸다. 열린 화살표가 붙어 있으면 그 끝으로 탐색할 수 있다는 뜻이다. 객체 참조를 필드에 보관하는 코드가 한 예다.
삼각형이 가리키는 쪽이 상위 타입이나 계약이다. 코드의 extends나 implements를 떠올리면 방향을 읽기 쉽다. 연관의 탐색 방향은 화살표나 범례로 확인한다. 방향 표시가 생략된 그림이라면 모델의 정의나 코드에서 어느 쪽이 참조를 갖는지 살펴본다.
빈 마름모는 집합(Shared aggregation), 채운 마름모는 합성(Composition)이다. 둘 다 마름모를 전체 쪽에 붙인다. 집합은 전체와 부분이라는 구분을 표시하지만, 부분의 소유·수명에 대한 구체적인 규칙은 모델에서 따로 정해야 한다.3
합성에서는 한 부분이 동시에 속할 수 있는 합성 전체가 최대 하나다. 전체를 삭제하면 그 안의 부분도 모델상 함께 삭제되며, 미리 떼어 낸 부분은 독립적으로 남을 수 있다. 코드에서는 이 수명 규칙에 맞게 메모리 정리나 데이터베이스 삭제 처리를 별도로 구현한다.
포매터 예제를 연결해서 읽어 보기
HumanFormatter에서IDiagnosticFormatter로 향하는 점선과 빈 삼각형은, 사람이 읽는 형식을 만드는 클래스가 공통 계약을 구현한다는 뜻이다.DiagnosticReporter에서IDiagnosticFormatter로 이어지는 실선은 출력기가 쓸 포매터를 참조한다는 표시다. 선 옆의 이름 formatter가 바로 그 속성이다.DiagnosticReporter에서Diagnostic으로 향하는 점선은 출력기가 report를 호출할 때 받은 진단 데이터를 쓴다는 뜻이다. 별도 필드에 계속 들고 있다는 이야기는 아니다.
연관선 끝에 붙은 숫자는 다중성(Multiplicity)이다. 출력기 쪽에서 보면 포매터 쪽의 1은 “포매터가 정확히 하나”라는 뜻이다. 반대편 0..*는 포매터 하나를 출력기 0개 이상이 참조할 수 있다는 뜻이고, 출력기 둘이 같은 포매터를 공유해도 문제없다.
0..1은 없거나 하나, 1..*는 하나 이상이다. 숫자는 “반대편 인스턴스 하나에 연결될 수 있는 이쪽 인스턴스 수”로 읽는다. 연관선에 숫자가 생략돼 있다면 다중성은 모델의 정의에서 확인한다.4
같은 구조를 코드로 보면
아래 두 예제는 구조도 결과도 같은데, 그림에서는 생성자를 생략했고 formatter 속성은 연관선으로 대신 표시했다. String은 문자열 타입이고, {readOnly}는 초기화 뒤에 값을 직접 바꿀 수 없는 속성을 뜻한다. 그림에 적은 메서드 이름은 TypeScript의 camelCase를 따랐고, C#에서는 관례대로 Format·Report·Message로 쓴다.
using System;
// 형식 변환에 필요한 데이터만 담는다.
public sealed record Diagnostic(string Message);
// 출력기가 호출할 수 있는 공통 계약이다.
public interface IDiagnosticFormatter
{
string Format(Diagnostic d);
}
public sealed class HumanFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic d) => "Error: " + d.Message;
}
public sealed class DiagnosticReporter
{
private readonly IDiagnosticFormatter formatter;
// 직접 구현체를 만들지 않고 외부에서 받은 포매터를 보관한다.
public DiagnosticReporter(IDiagnosticFormatter formatter)
{
this.formatter = formatter;
}
// 현재 포매터에 변환을 맡기고 결과를 반환한다.
public string Report(Diagnostic d) => formatter.Format(d);
}
public static class Program
{
public static void Main()
{
var reporter = new DiagnosticReporter(new HumanFormatter());
Console.WriteLine(reporter.Report(new Diagnostic("Name not found.")));
}
}// 형식 변환에 필요한 데이터만 담는다.
class Diagnostic {
constructor(public readonly message: string) {}
}
// 출력기가 호출할 수 있는 공통 계약이다.
interface IDiagnosticFormatter {
format(d: Diagnostic): string;
}
class HumanFormatter implements IDiagnosticFormatter {
format(d: Diagnostic): string {
return "Error: " + d.message;
}
}
class DiagnosticReporter {
// 직접 구현체를 만들지 않고 외부에서 받은 포매터를 보관한다.
constructor(private readonly formatter: IDiagnosticFormatter) {}
// 현재 포매터에 변환을 맡기고 결과를 반환한다.
report(d: Diagnostic): string {
return this.formatter.format(d);
}
}
const reporter = new DiagnosticReporter(new HumanFormatter());
console.log(reporter.report(new Diagnostic("Name not found.")));두 예제의 출력 결과는 같다.
Error: Name not found.출력기는 포매터를 직접 만들지 않고, 호출자가 생성자에 넘겨준 포매터를 보관해 두고 변환을 맡긴다. 다른 형식을 만드는 클래스를 추가해도 출력기의 Report 또는 report 메서드는 그대로 둘 수 있다. 이런 역할 분리를 더 알고 싶다면 전략 패턴 항목을 보면 된다.
시퀀스 다이어그램은 한 번의 실행을 따라간다
이번에는 같은 예제에서 report를 한 번 호출하는 상황을 그려 보자. 클래스 그림이 타입 사이의 관계를 보여 줬다면, 여기서는 실제로 참여한 객체 사이의 호출과 반환이 보인다.
맨 위의 상자는 참여자를 뜻한다.
reporter: DiagnosticReporter는 reporter라는 객체가 DiagnosticReporter 타입이라는 표기다.상자에서 아래로 이어진 점선은 생명선(Lifeline)이다. 그 실행에 참여하는 객체를 따라 길게 그은 선이다.
실선에 채운 화살표를 붙인 것이 동기 호출이며, 호출자는 그 호출이 반환될 때까지 기다린다. 점선에 열린 화살표를 붙이면 반환을 뜻한다.
생명선 위에 얹힌 가느다란 직사각형은 실행 구간이다. reporter가 호출을 처리하는 동안 human에 변환을 맡기는 구간이 그 안쪽에 놓인다.
이 예제는 위에서 아래로 호출과 반환을 따라 읽으면 된다. 한 생명선 안의 사건 순서와 메시지의 송수신 관계를 함께 보고, 서로 독립적인 생명선의 사건은 각자의 흐름으로 읽는다. 세로 간격은 그림의 배치로 보고, 실제 처리 시간은 별도로 표시한 시간 값이나 제약으로 확인한다.5
상태 머신 다이어그램은 허용되는 변화를 본다
상태 머신은 음악 플레이어로 살펴보자. 정지 상태에서 재생을 누르면 음악이 나오고, 재생 중에 일시정지를 누르면 멈춘 위치를 기억한다. 여기서는 정지·재생·일시정지 세 상태를 두고, 각 버튼을 눌렀을 때 어디로 이동하는지 그린다.
작은 검은 원은 시작 위치를 표시하는데, 이 그림에서는 문서가 Draft에서 시작한다.
둥근 상자는 상태다.
Stopped·Playing·Paused가 각각 정지·재생·일시정지를 뜻한다.화살표는 전이(Transition)다.
play를 누르면 Stopped에서 Playing으로,pause를 누르면 Paused로 이동한다.resume은 재생을 이어 가고,stop은 재생 중이거나 일시정지 상태인 플레이어를 Stopped로 돌린다.
전이에는 사건 [조건] / 동작을 적을 수 있다. Stopped에서 Playing으로 가는 화살표에 play [trackLoaded] / startAudio()라고 적으면 “재생 사건이 왔을 때 곡이 준비돼 있으면, 소리를 재생하며 Playing으로 간다”는 뜻이다. 조건과 동작은 필요한 전이에 붙인다.6
상태도는 동작 규칙을 그림으로 옮긴 것이고, State 패턴은 상태별 처리를 객체로 나눠 담는 코드 구성 방식이다. 그렇다고 이 상태도를 반드시 State 패턴으로 구현해야 하는 것은 아니고, 모델이 작으면 enum과 조건문으로도 충분하다.
직접 그릴 때의 순서
먼저 질문을 하나 정하는데, “누가 누구를 참조하는가”와 “어떤 순서로 호출하는가”는 서로 다른 그림을 요구한다.
그 질문에 답하는 데 필요한 타입이나 객체만 넣으면 되고, 생성자나 보조 메서드를 전부 나열할 필요는 없다.
실선·점선·삼각형·마름모의 뜻을 맞춰 둔 다음, 필요한 역할 이름과 다중성을 붙인다. 표준과 다르게 그린 부분이 있으면 범례로 밝힌다.
마지막으로 코드와 대조하는데, 참조를 보관하는지, 매개변수로만 쓰는지, 인터페이스를 구현하는지 하나씩 확인한다. 코드가 바뀌면 설명에 쓰는 그림도 같이 고친다.
예제 실행 환경부터 밝히면, C#은 .NET 10, TypeScript는 ES2022를 대상으로 컴파일해 Node.js에서 실행했다.
같이 보기
전략 패턴: 인터페이스와 구현체의 역할 분리
상태 패턴: 상태별 동작과 전이
OMG UML 2.5.1: 명세와 버전 정보
각주
- OMG, What is UML? UML의 목적과 모델링 범위. 이 글의 세부 표기는 UML 2.5.1 명세를 기준으로 한다. ↩
- OMG UML 2.5.1, §9.2.4 Classifier notation, §9.4.4 Feature notation, §9.5.4 Property notation. ↩
- OMG UML 2.5.1, §7.7.4 Dependencies, §9.2.4.2 Generalization, §9.5.3 AggregationKind, §11.5.4 Associations. ↩
- OMG UML 2.5.1, §7.5.4 Multiplicity notation, §11.5.4 Associations. 연관 다이어그램의 다중성 생략과 속성의 텍스트 표기 기본값은 구별해야 한다. ↩
- OMG UML 2.5.1, §17.1.3 Partial ordering, §17.3.4 Lifelines, §17.4.4.1 Messages. ↩
- OMG UML 2.5.1, §14.2.4 States, Pseudostates, Transitions. ↩