UML
UML은 무엇인가?
UML(Unified Modeling Language, 통합 모델링 언어)은 소프트웨어의 구조와 동작을 그림으로 표현하는 표기 언어다. 클래스 사이의 관계, 객체가 메시지를 주고받는 순서, 상태가 바뀌는 조건처럼 코드에서 한눈에 보기 어려운 내용을 골라 보여 준다.1
UML은 프로그래밍 언어도, 특정 개발 방법론도 아니다. 그림을 그리는 도구와도 구별된다. 같은 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..*는 하나 이상이다. 숫자는 “반대편 인스턴스 하나에 연결될 수 있는 이쪽 인스턴스 수”로 읽는다. 연관선에서 숫자가 생략되어 있다면 그림만 보고 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에서 시작한다.
둥근 상자는 상태다. Draft·InReview·Published가 각각 초안·검토 중·발행됨을 뜻한다.
화살표는 전이(Transition)다. submit이 오면 Draft에서 InReview로, approve가 오면 InReview에서 Published로 바뀐다. reject(reason)는 초안으로 되돌린다.
전이에는 사건 [조건] / 동작을 적을 수 있다. 예를 들어 submit [contentNotEmpty] / lockEditing()은 “제출 사건이 발생하고 내용이 비어 있지 않으면, 편집을 잠그면서 다음 상태로 간다”는 뜻이다. 필요 없는 조건이나 동작은 생략한다.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. ↩